A Simple Portal System for Godot 4 (and 3 with a little work). Portals hopefully need no introduction. Just think of the game Portal and you get the idea. Non-nested portals are deceptively simple to implement, and can be incredibly powerful as both a gameplay mechanic and as a convenience feature to move players around your level, or to provide countless other fun special effects.
This simple portal system is meant as an educational example on how you can create portals in Godot. Consider it a starting point, as the relevant portal code has been documented clearly.
In essence, portals are an illusion created by placing a virtual "exit camera" behind the exit portal. This camera replicates the main player camera's relative position to the entrance portal. As a result, both the player and the exit camera view the entrance and exit portals as if they occupy the same screen space. The visuals seen by the exit camera are rendered onto a 'viewport' in Godot (a render target), which is then overlaid on the entrance portal through a screen-space shader. This gives the impression that what's in front of the exit portal is visible through the entrance portal.
This repository contains a small demo project which shows the portals in action. You can move around using WASD or the arrow keys, and look around using the mouse. A raycaster will show you which crates you hit as you move the mouse cursor.
If you want to use the portals in your own project, you only need these two files:
src/shaders/portal.gdshader
src/scripts/portal.gd
The shader is very simple and just renders a screen-space texture and handles fade-out.
The portal script handles the creation of a viewport and virtual exit camera. In _process
the exit camera position is updated according to the main camera.
In addition, the _process
function handles adjusting the near clipping plane of the exit camera to find a compromise between not rendering objects behind the portal, and not cutting off the portal itself. This is done by getting the world position of the four X, Y corners of the entrance portal bounding box relative to (and scaled by) the exit camera. These corners are then projected onto the exit camera forward vector to get the near clipping distance which contain the corners within the camera frustum. The reason the entrance portal bounding box is used rather than the exit portal bounding box is because the entrance and exit meshes does not need to match each other, and you are looking through the entrance mesh, not the exit mesh.
The portal class also has functions for transforming between frames of reference and raycasting.
In addition, an example is provided on how to handle basic teleportation through portals. See the teleportation section.
First you need to model some portal meshes, or just use a plane or a box.
- The portal model surface should face -Y in Blender and +Z in Godot.
- To make a portal face another way, rotate the model object, not the mesh.
- The raycasting works by treating the portal as a flat surface centered at Z=0 in Godot. Flat portals work best in raycasting.
Note: Portals are resource-intensive to render and consume texture memory. Disable portals that are far away from the player by using
disable_viewport_distance
. Furthermore, if you have many portals, consider enablingdestroy_disabled_viewport
to conserve texture memory, as this will destroy the viewport of disabled portals. However, this may potentially cause a small hickup when the viewport is re-created midgame.
Note: Ensure that the parent hierarchy of the portal is uniformly scaled. Non-uniform scaling in the parent hierarchy can introduce skewing effects, which may lead to unexpected or incorrect behavior of the portal. However, scaling the portal itself in a non-uniform way is okay since it is handled by the transformations.
- Attach the
portal.gd
script to twoMeshInstance3D
nodes that represent your portal surfaces. - Establish a connection between the two portals: Assign one portal to the
exit_portal
property of the other portal. For a one-way portal, leave one portal disconnected. - Set your primary camera to the
main_camera
property. If left unset, the portal will default to the primary camera if one exists. - Set the
vertical_viewport_resolution
of the viewport rendering the portal (which covers the entire screen). Set to 0 to automatically use the real screen resolution. - Define the fading range for the portal using
fade_out_distance_max
andfade_out_distance_min
. Fades tofade_out_color
. - Define the
disable_viewport_distance
for portal rendering. Put the value slightly abovefade_out_distance_max
to ensure the portal fades out completely before disabling itself. If you have a lot of portals it may be beneficial to enabledestroy_disabled_viewport
, which will free up texture memory when the portal is disabled by destroying the viewport, at the cost of the overhead of re-creating the viewport again later. - Define the
exit_scale
to adjust the exit portal's view scale. Imagine, for instance, a large door leading to a small door. - Adjust the
exit_near_subtract
if objects behind the exit portal get cut off. At 0 the portal exit is roughly cut at Z=0. - Set
exit_environment
to assign a specific environment to a portal. This is important if, for instance, you want to prevent environmental effects from being applied twice.
These functions aid in transitioning between the portal entrance and exit frames of reference:
real_to_exit_transform(real:Transform3D) -> Transform3D
real_to_exit_position(real:Vector3) -> Vector3
real_to_exit_direction(real:Vector3) -> Vector3
These are useful when you manipulate portal-associated objects. For instance, these functions would allow you to position a cloned spotlight at the exit portal:
clone_spotlight.global_transform = portal.real_to_exit_transform(real_spotlight.global_transform)
This code can also be used to teleport an object to the exit portal. Alternatively use real_to_exit_position
if you only want to change the global position of your object.
Note: Portals currently do not nest (ie, you can't see through two portals at once). To nest portals you'd have to update the exit_camera position in-between draw calls, or figure out a way to change the camera view matrix in-between rendering viewports. That is beyond the scope of this simple system, but if you got some nice ideas how to implement these things in godot, please open an issue.
If the player in your game has a physical shape, such as hands or a body, having these parts cross the portal surface before teleporting the player to the exit will cause them to disappear. There are several solutions to this issue:
- Create a dummy player at the exit portal and move it using
real_to_exit_transform
. This stand-in will replace the missing player as it moves through the portal. - Instead of a single portal, create two pairs of one-way portals with a buffer zone between them. This allows the player to fully enter the buffer zone before being teleported to the exit. Keep in mind that this forces you to model the same buffer zone on both sides of the portal.
- With a single pair of portals, create a buffer zone by moving the mesh surface backward along the Z-axis (+Y in Blender). Note that since the exit camera's near clipping range calculations assume the mesh is at Z=0, you may need to adjust the near clipping range through
exit_near_subtract
or another method. Anecdotally, in my game, I prefer making the portals shaped like a hard-edged lens like/‾‾‾‾\
rather than______
. This provides a buffer zone for the player to move their hand in while being flat around the portal edges. This kind of portal has depth if you look at it from the side, but it doesn't need you to model a buffer zone. See the example in the teleportation section.
A teleportation script called portal_teleport.gd
is provided with the sample scene. This script attaches to an Area3D node, which is parented to a portal node. Create a CollisionShape3D as a child node of the Area3D node. This shape should be right behind the portal, acting as a trigger to teleport objects entering the portal.
To trigger the teleportation, you need to define another Area3D with a CollisionShape3D on the object you want to teleport. Then, give it a Metadata element called teleportable_root
to which you add a NodePath pointing towards the root of the composite object you want to teleport (See the Player
in the sample).
PortalTeleport Area3D
:
- Monitoring
- Monitorable
Teleportable Object Area3D
:
- Monitoring
- Monitorable
The portals in the example scene have a buffer zone built into them. This allows the player's camera to turn around fully without clipping through the portal. If your player has a hand, you may also need to move the teleport trigger in front of the camera so that the player teleports before their hand clips through the portal.
Raycasting through portals can be complex. To simplify this, a built-in raycasting function is provided.
Define a function with this signature, in which you do your own raycasting against non-portal objects:
func _handle_raycast(from:Vector3, dir:Vector3, segment_distance:float, recursive_distance:float, recursions:int) -> bool:
Declare a callable for your function:
var callable:Callable = Callable(self, "_handle_raycast")
Then, use the built-in raycasting function as follows:
Portal.raycast(get_tree(), from_position, direction, callable, [max_distance=INF], [max_recursions=2], [ignore_backside=true])
_handle_raycast
is always invoked at least once for the original ray. The segment_distance
is INF
if no portal was hit, or the distance to the hit portal. The function is invoked once more each time the ray recursively passes through another portal. A ray can be prematurely interrupted if _handle_raycast
returns true, or if it hits the max_recursions
limit. Return true if for example the current ray segment (within segment_distance
) was blocked by something.
If you want to manually raycast, you can adapt the code in the Portal.raycast function to suit your requirements. Assuming you know a ray hits the entrance portal, you can get the corresponding exit ray using:
exit_position = portal.real_to_exit_position(position)
exit_direction = portal.real_to_exit_direction(direction)
If you find any bugs or have feedback, please open an issue in the GitHub repository.