Multiple Recursive Portals and AI In Unity Part 2: Portal teleportation

Lim Ding Wen
17 min readMay 30, 2020

--

This tutorial is part of a multi-part series. See the table of contents here.

In the last tutorial, we learnt how to render portals. However, when the player tries to step through the portal, they are blocked by the default Quad MeshCollider. In this tutorial, we’ll learn how to allow the player to seamlessly step through the portal, teleporting them to the other side.

The final effect

This tutorial is made with Unity 2019.3.7f1. It relies on the final project from the previous tutorial, Part 1. If you don’t have it, feel free to download the previous project or the starter kit from the links below.

Repositories

You can find the repositories here: https://github.com/limdingwen/Portal-Tutorial-Repository

Download by clicking “Clone or Download”.

For this tutorial, these are the 3 relevant folders:

  • Part 1 Final: The previous project, includes Part 1 premade
  • Part 2 Starter: Starter kit, includes Part 1 premade
  • Part 2 Final: Final project, includes Part 2 premade

If you just want the full scripts, you can find them at the very end of this page.

Let’s begin!

Portal theory

We first detect if the player is touching the portal, using the trigger. If they are, start tracking the player’s position. Once they cross the portal’s position, which can be calculated cheaply using a dot product, we teleport the player to the corresponding position and rotation at the target portal. We will also modify the player’s velocity if there is any.

However, as you’ll soon see, there are some gotchas that come with teleportation. This mostly comes in the form of execution order, which might cause distracting 1-frame flashes when teleporting.

Moving and teleporting with Update()

Let’s see what happens if we move the player in Update(), as we are doing currently, and then teleport the player in LateUpdate().

For our first situation, the player is moving slow enough that there is at least 1 frame difference between the player touching the portal, and the player crossing the portal’s position.

For reference, you can take a look at Unity’s execution order.

Frame 0

  1. OnTriggerEnter (nothing happens)
  2. Update (player is not touching portal yet)
  3. LateUpdate (not tracking player; nothing happens)
  4. Rendering (player sees portal normally)

Frame 1

  1. OnTriggerEnter (nothing happens)
  2. Update (player is now touching portal, but hasn’t crossed portal)
  3. LateUpdate (not tracking player; nothing happens)
  4. Rendering (player sees portal normally)

Frame 2

Both portal crossing and teleportation happen in the same frame.
  1. OnTriggerEnter (detects player as touching the portal, start tracking)
  2. Update (player is still touching portal, now passed through)
  3. LateUpdate (tracking player; passed through; teleport player)
  4. Rendering (player has teleported and is now seeing the other side)

As you can see, it works well. However, what if the player starts moving fast enough to both touch the portal and cross it within the same frame?

Frame 0:

  1. OnTriggerEnter (nothing happens)
  2. Update (player is not touching portal yet)
  3. LateUpdate (not tracking player; nothing happens)
  4. Rendering (player sees portal normally)

Frame 1:

Because the player moves fast enough, he crosses the portal instantly. Due to the execution order, OnTriggerEnter fails to detect the portal-touching this frame.
  1. OnTriggerEnter (nothing happens, the player hasn’t crossed yet!)
  2. Update (player touches portal and crosses portal)
  3. LateUpdate (not tracking player; player not teleported)
  4. Rendering (BUG! Player sees behind the portal!)

Frame 2:

  1. OnTriggerEnter (detects player as touching the portal, start tracking)
  2. Update (player is still touching portal, now passed through)
  3. LateUpdate (tracking player; passed through; teleport player)
  4. Rendering (player has teleported and is now seeing the other side)

As you can see, once the player starts to move fast enough, there might be 1-frame flashes where the player is able to see behind the portal. This is very obvious and breaks the illusion entirely, as well as being very annoying.

Here’s an example of it happening, at the start of the video. This is a video of an early prototype of my game, Portal Arena Shooter:

So how do we fix this? We’ll need to move before OnTriggerEnter() so that we get a chance to detect the player going into the trigger on the same frame. Then, we’ll need to teleport the player after OnTriggerEnter(). This means doing things with FixedUpdate() instead of Update().

Moving and teleporting with FixedUpdate()

Right now, the player is moving by calling characterController.SimpleMove() every Update(). While this worked for the previous tutorial and gave very smooth motion, we need to use FixedUpdate() so that we can move the player before OnTriggerEnter() is called.

An alternative explanation is that since the player is now interacting with the physics world through triggers, it’ll be better to move them through FixedUpdate(). Personally, I also use FixedUpdate() instead of Update() to favour determinism and decrease chances of my game breaking under low FPS conditions.

We’ll also need to check for player teleportation after OnTriggerEnter(). Unfortunately, Unity does not provide a sort of LateFixedUpdate() function. However, it does include support for WaitForFixedUpdate(), which can be used in coroutines and is called after OnTriggerEnter().

Let’s see how this works in a situation where the player is moving fast enough to both touch the portal and cross it in the same physics tick.

Tick 0

  1. FixedUpdate (player is not touching portal yet)
  2. OnTriggerEnter (nothing happens)
  3. WaitForFixedUpdate (not tracked; nothing happens)
  4. Rendering (player sees portal normally)

Tick 1

  1. FixedUpdate (player is touching the portal; has crossed portal)
  2. OnTriggerEnter (detect player touching the portal; start tracking)
  3. WaitForFixedUpdate (tracking player; player crossed; teleport player)
  4. Rendering (player sees the other side of the portal normally)

Great! So that will work. This is the approach that Quantum Tournament v0.1.0 takes. Let’s get to implementing this.

Modifying the player

Let’s make the player move in FixedUpdate() first.

private void FixedUpdate()
{
// Turn player

transform.Rotate(Vector3.up * Input.GetAxis("Mouse X") * turnSpeed);

// Move player

characterController.SimpleMove(
transform.forward * Input.GetAxis("Vertical") * moveSpeed +
transform.right * Input.GetAxis("Horizontal") * moveSpeed);
}

However, Input.GetAxis(“Mouse X”) returns the delta of the mouse position from the last frame, not the last tick. Because FixedUpdate() may run anywhere from 0 to many times per frame, if we read Input.GetAxis(“Mouse X”) within FixedUpdate(), we will miss frames of mouse input, resulting in stuttery motion.

The solution to this is to buffer our mouse movement in Update(), and then consume the mouse movement in FixedUpdate(). Let’s define a variable to hold this buffer first:

private float turnRotation;

Then, we shall buffer the mouse movement:

private void Update()
{
turnRotation += Input.GetAxis("Mouse X");
}

Finally, we shall consume the buffer on FixedUpdate(), instead of reading directly from the input:

// Turn player

transform.Rotate(Vector3.up * turnRotation * turnSpeed);
turnRotation = 0; // Consume variable

Great! Our player now moves in FixedUpdate(), and we don’t miss any input.

You may have noticed a downside to this: If you strafe and continuously move your mouse to face the pillar, you’ll notice stuttering. This is due to the player moving during FixedUpdate() but only changing look direction, effectively, in Update(). The proper way to solve this is through interpolation, but that is out of scope for this tutorial. Check out this article to learn more.

Creating the trigger

Open up the Portal prefab by double-clicking on it in the Project tab. In there, delete the MeshCollider component that came with the default quad.

Next, add a new BoxCollider on the quad object. Check the “Is Trigger” checkbox. By default, the BoxCollider automatically fits the shape of the quad, with a very small Z scale value. Let’s widen it up a bit to make sure our trigger detects the player even if the player is moving fast enough to tunnel through the portal.

Scripting the teleport

First, let’s create a new type of MonoBehaviour called PortalableObject.cs. Attach it to the player. For now, we’ll just use it as an identifier:

using UnityEngine;

public class PortalableObject : MonoBehaviour
{

}

Next, we’ll use this to track Portalable Objects as they touch the portal, and stop tracking them as they stop touching the portal. Let’s first declare a HashSet that can be used to track the objects in Portal.cs:

private HashSet<PortalableObject> objectsInPortal = new HashSet<PortalableObject>();

Why HashSet instead of List?

List is great for ordered elements which allows for duplication, but is not so great when adding and removing from it randomly. As we don’t want duplicated tracked objects, and we also need to add/remove randomly, HashSet is the perfect data structure for our needs.

We’ll also declare a HashSet that we’ll be using to remove portalable objects from our earlier HashSet when we are inside a loop. When you are enumerating a data structure in C#, you may not remove anything from it. Therefore, we’ll use this extra HashSet to postpone removing until after the loop has ended.

private HashSet<PortalableObject> objectsInPortalToRemove = new HashSet<PortalableObject>();

Next, we’ll use OnTriggerEnter() to start tracking touches:

private void OnTriggerEnter(Collider other)
{
var portalableObject = other.GetComponent<PortalableObject>();
if (portalableObject)
{
objectsInPortal.Add(portalableObject);
}
}

The same for OnTriggerExit(), just in reverse:

private void OnTriggerExit(Collider other)
{
var portalableObject = other.GetComponent<PortalableObject>();
if (portalableObject)
{
objectsInPortal.Remove(portalableObject);
}
}

Next, we’ll need to check if any tracked objects have crossed the portal, and if so, teleport them. Recall that we need to do this check after OnTriggerEnter(). Unity doesn't provide a LateFixedUpdate() function, but it does provide WaitForFixedUpdate(). That is exactly what we’re going to use. Add this into your Start() function:

// Generate bounding plane

var plane = new Plane(normalVisible.forward, transform.position);
vectorPlane = new Vector4(plane.normal.x, plane.normal.y, plane.normal.z, plane.distance);
StartCoroutine(WaitForFixedUpdateLoop());

For the coroutine, we’ll just use an infinite loop, waiting for the fixed update to finish before calling our portal teleportation code. We pre-allocate WaitForFixedUpdate() so we don’t create a new object every tick. This is good for reducing garbage. We also catch any exceptions that happen in our teleportation logic and log them. That way, if any exceptions occur, the loop will continue on.

private IEnumerator WaitForFixedUpdateLoop()
{
var waitForFixedUpdate = new WaitForFixedUpdate();
while (true)
{
yield return waitForFixedUpdate;
try
{
CheckForPortalCrossing();
}
catch (Exception e)
{
// Catch exceptions so our loop doesn't die whenever there is an error
Debug.LogException(e);
}
}
}

For the teleportation code, we’ll start off by clearing the removal HashSet, in case there’s anything left over from the previous ticks.

private void CheckForPortalCrossing()
{
// Clear removal queue

objectsInPortalToRemove.Clear();

Why use HashSet.Clear() when we can just use new?

Using new allocates garbage. We don’t want to allocate garbage every frame. Instead, we’ll allocate it once, at the start, and just clear it every frame instead.

We’ll then start looping over every object that is touching the portal currently. If any of the objects are null, which also checks for being destroyed in Unity, we’ll remove them from the HashSet and continue on to the next object.

// Check every touching object

foreach (var portalableObject in objectsInPortal)
{
// If portalable object has been destroyed, remove it immediately

if (portalableObject == null)
{
objectsInPortalToRemove.Add(portalableObject);
continue;
}

We’ll now check if the Portalable Object is behind the portal. To do this, we first calculate the direction from the portal to the object (which we’ll call the pivot here), using some good old Vector maths.

// Check if portalable object is behind the portal using Vector3.Dot (dot product)
// If so, they have crossed through the portal.

var pivot = portalableObject.transform;
var directionToPivotFromTransform = pivot.position - transform.position;
directionToPivotFromTransform.Normalize();

Next, we’ll use a dot product to check if the object is behind the portal. Quoting from the documentation:

For normalized vectors Dot returns 1 if they point in exactly the same direction, -1 if they point in completely opposite directions and zero if the vectors are perpendicular.

In other words, we can use this function to compare our portal-to-object direction and the direction of our visible normal.

If the dot product is more than 0, that means that the portal-to-object direction and the visible normal direction is on the same side, which means that the object has not crossed the portal yet, and we should continue on to the next object.

var pivotToNormalDotProduct = Vector3.Dot(directionToPivotFromTransform, normalVisible.forward);
if (pivotToNormalDotProduct > 0) continue;

If we reach this point, that means the object has crossed the portal. Time to teleport it! Let’s first calculate the new positions and rotations to teleport the object to, using our old helper functions:

// Warp object

var newPosition = TransformPositionBetweenPortals(this, targetPortal, portalableObject.transform.position);
var newRotation = TransformRotationBetweenPortals(this, targetPortal, portalableObject.transform.rotation);

And finally, we’ll set them both together:

portalableObject.transform.SetPositionAndRotation(newPosition, newRotation);

Since the object is no longer touching this portal after teleportation, let’s clean up after ourselves.

// Object is no longer touching this side of the portal

objectsInPortalToRemove.Add(portalableObject);

Finally, we end the loop and start removing all the objects queued up for removal from inside the loop.

}

// Remove all objects queued up for removal

foreach (var portalableObject in objectsInPortalToRemove)
{
objectsInPortal.Remove(portalableObject);
}

Click play!

That’s weird, what is happening? Our rotation seems to be getting changed, but once we get our bearings, it seems like our position hasn’t changed at all! The player ends up behind the portal. Why is that?

Playing nice with CharacterController

This has been a reported issue in Unity, in that the CharacterController seems to override an object’s position when teleporting it, by modifying the transform position directly. This is by design, however. Quoting from Unity’s comment here:

The problem here is that auto sync transforms is disabled in the physics settings, so characterController.Move() won’t necessarily be aware of the new pose as set by the transform unless a FixedUpdate or Physics.Simulate() called happened in-between transform.position and CC.Move().

In other words, an internal physics simulation (which happens immediately after FixedUpdate()) must happen between transform.position and CharacterController.Move(). Let’s think back to how our execution order works.

  1. Tick 0 FixedUpdate (CharacterController.Move())
  2. Tick 0 Internal physics simulation
  3. Tick 0 WaitForFixedUpdate (transform.position; teleport player)
  4. Tick 1 FixedUpdate (CharacterController.Move())
  5. Tick 1 Internal physics simulation
  6. Tick 1 WaitForFixedUpdate (teleport player…)

As you can see, between teleporting the player in Step 3 and moving the player in Step 4, no internal physics simulation occurs. That is why we’re encountering this issue. So how do we solve this?

To fix that, either enable auto sync transforms in the physics settings, or sync manually via Physics.SyncTransforms right before calling Move().

We basically force Unity to update the transforms within the physics engine, which tells the Character Controller our new positions. Great!

We can do this by simply dropping in a Physics.SyncTransforms() call right after the teleport. However, as we only need to do this for Character Controllers, we can do this in a more generic way instead of doing it for all teleports. Plus, we can have object-specific logic for teleportation. For example, NavMeshAgents (covered in a future tutorial) will need to recalculate every time teleportation occurs.

Go back into PortalableObject.cs. We’re going to add in an event, HasTeleported, which will fire off whenever a teleport has finished occurring.

public delegate void HasTeleportedHandler(Portal sender, Portal destination, Vector3 newPosition, Quaternion newRotation);
public event HasTeleportedHandler HasTeleported;

To make this event invoke-able from Portal.cs, we’ll add in a public function:

public void OnHasTeleported(Portal sender, Portal destination, Vector3 newPosition, Quaternion newRotation)
{
HasTeleported?.Invoke(sender, destination, newPosition, newRotation);
}

Go to Portal.cs. Call the function we just added, after the teleport:

var newPosition = TransformPositionBetweenPortals(this, targetPortal, portalableObject.transform.position);
var newRotation = TransformRotationBetweenPortals(this, targetPortal, portalableObject.transform.rotation);
portalableObject.transform.SetPositionAndRotation(newPosition, newRotation);
portalableObject.OnHasTeleported(this, targetPortal, newPosition, newRotation);

Finally, go to PlayerController.cs. Here, we’ll subscribe to the HasTeleported event we just made.

private void Start()
{
characterController = GetComponent<CharacterController>();
portalableObject = GetComponent<PortalableObject>();
portalableObject.HasTeleported += PortalableObjectOnHasTeleported;
}

Whenever a teleport occurs, we’ll perform the Physics.SyncTransforms() call as described earlier.

private void PortalableObjectOnHasTeleported(Portal sender, Portal destination, Vector3 newposition, Quaternion newrotation)
{
// For character controller to update

Physics.SyncTransforms();
}

Don’t forget to clean up after yourself, with C# events:

private void OnDestroy()
{
portalableObject.HasTeleported -= PortalableObjectOnHasTeleported;
}

Click play!

Well… it kinda works! Aside from the “screen wipe” artefact, the teleport works, and our player ends up on the other side of the portal. We’ll just have to get rid of the graphical artefacts.

The “screen wipe” artefact.

Modifying the near clip plane

The “screen wipe” artefact happens because the camera is too close to the portal, which causes the portal to be cut off due to the near clip plane.

The first thing we can do is to modify the no clip plane of the main camera. The smaller the near clip plane value is, the less the camera will cut out very near objects, which means that the portal will get cut off less as the player gets nearer to it.

However, this comes with a downside that it’ll lower floating-point precision if we have a high far clip plane value, which may result in increased Z-fighting. We can ignore that downside for our game since our game is only situated in small, indoor areas.

The simplest way to do this is to go into the Main Camera and lower the near clip plane value to lowest possible value, 0.01.

Click play!

The artefact happens much more rarely, but it still happens occasionally.

As you can see, it works, but we can still see graphical artefacts. We can do even better by lowering the near clip plane value even more in code.

Let’s create a new script called NearClipPlane.cs, and add in the following code:

using UnityEngine;

public class NearClipPlane : MonoBehaviour
{
public float nearClipPlane = 0.0001f;

private void Start()
{
GetComponent<Camera>().nearClipPlane = nearClipPlane;
}
}

Attach it to the Main Camera. When you play the scene, the Camera’s near clip plane will be set, in this case, to 0.0001. You can change it in the inspector, but don’t set it to 0, as that’ll result in a black screen.

If you ever notice problems with shadows, Z-fighting or rendering in general, you can lower the far clip plane’s value on the main camera, or increase the near clip plane to around 0.001 instead.

You don’t need to attach this to the Portal Camera. Recall that, in Part 1, we’re using the Main Camera’s projection matrix to calculate an oblique matrix that overrides the Portal Camera’s own projection matrix. That causes the Portal Camera to inherit some of Main Camera’s settings, including the near clip plane.

Do note that this doesn’t completely remove the artefact, but it does make it happen a lot less often. To completely remove it, you need to slightly offset the camera if it gets too close to the portal*, or do some rendering tricks. Either way, that is out of scope for this tutorial.

*This is what Quantum Tournament v0.1.0 did for the main menu screen’s custom camera. When the camera came too close to one of the portals, it would be offset by a tiny, unnoticeable amount, getting rid of all artefacts.

Single-pixel line artefacts

In both Part 1 and this tutorial, you may have seen seams where the portals meet the walls. They’re usually 1 pixel wide, and you can see the skybox or other parts of the level through it. If this issue doesn’t occur to you, you may skip this section.

The solution is to disable MSAA. You can do that by either going into Project Settings > Quality > Antialiasing > Disabled, or you can go into both the Main Camera and the Portal Camera and choosing MSAA > Off.

You may wish to experiment more with other types of post-processing AA that come with Unity instead, such as FXAA and SMAA.

Final result

Here’s the final result of how our effect looks like.

Looks great! We have effectively built a non-euclidean space, a room with only 3 corners, and the player is able to walk around the room seamlessly.

Closing

In this tutorial, you have learnt how to teleport the player (and potentially other portalable objects in the future) as they walk through portals seamlessly. You have also learnt how to cut down on graphical artefacts when walking through portals.

In the next tutorial, we’ll discuss how to shoot raycasts through portals so that we can “shoot” a cube.

Author’s note

Did you enjoy this tutorial? Did it help you make a game with portal-like mechanics? If so, please consider buying me a cup of coffee. Thank you! ❤

Full scripts

PlayerController.cs

using UnityEngine;

public class PlayerController : MonoBehaviour
{
private CharacterController characterController;
private PortalableObject portalableObject;

public float moveSpeed = 5;
public float turnSpeed = 5;

private float turnRotation;

private void Start()
{
characterController = GetComponent<CharacterController>();
portalableObject = GetComponent<PortalableObject>();
portalableObject.HasTeleported += PortalableObjectOnHasTeleported;
}

private void PortalableObjectOnHasTeleported(Portal sender, Portal destination, Vector3 newposition, Quaternion newrotation)
{
// For character controller to update

Physics.SyncTransforms();
}

private void FixedUpdate()
{
// Turn player

transform.Rotate(Vector3.up * turnRotation * turnSpeed);
turnRotation = 0; // Consume variable

// Move player

characterController.SimpleMove(
transform.forward * Input.GetAxis("Vertical") * moveSpeed +
transform.right * Input.GetAxis("Horizontal") * moveSpeed);
}

private void Update()
{
turnRotation += Input.GetAxis("Mouse X");
}

private void OnDestroy()
{
portalableObject.HasTeleported -= PortalableObjectOnHasTeleported;
}
}

Portal.cs

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Portal : MonoBehaviour
{
public Portal targetPortal;

public Transform normalVisible;
public Transform normalInvisible;

public Camera portalCamera;
public Renderer viewthroughRenderer;
private RenderTexture viewthroughRenderTexture;
private Material viewthroughMaterial;

private Camera mainCamera;

private Vector4 vectorPlane;

private HashSet<PortalableObject> objectsInPortal = new HashSet<PortalableObject>();
private HashSet<PortalableObject> objectsInPortalToRemove = new HashSet<PortalableObject>();

public static Vector3 TransformPositionBetweenPortals(Portal sender, Portal target, Vector3 position)
{
return
target.normalInvisible.TransformPoint(
sender.normalVisible.InverseTransformPoint(position));
}

public static Quaternion TransformRotationBetweenPortals(Portal sender, Portal target, Quaternion rotation)
{
return
target.normalInvisible.rotation *
Quaternion.Inverse(sender.normalVisible.rotation) *
rotation;
}

private void Start()
{
// Create render texture

viewthroughRenderTexture = new RenderTexture(Screen.width, Screen.height, 24, RenderTextureFormat.DefaultHDR);
viewthroughRenderTexture.Create();

// Assign render texture to portal material (cloned)

viewthroughMaterial = viewthroughRenderer.material;
viewthroughMaterial.mainTexture = viewthroughRenderTexture;

// Assign render texture to portal camera

portalCamera.targetTexture = viewthroughRenderTexture;

// Cache the main camera

mainCamera = Camera.main;

// Generate bounding plane

var plane = new Plane(normalVisible.forward, transform.position + normalInvisible.forward * 0.01f);
vectorPlane = new Vector4(plane.normal.x, plane.normal.y, plane.normal.z, plane.distance);

StartCoroutine(WaitForFixedUpdateLoop());
}

private IEnumerator WaitForFixedUpdateLoop()
{
var waitForFixedUpdate = new WaitForFixedUpdate();
while (true)
{
yield return waitForFixedUpdate;
try
{
CheckForPortalCrossing();
}
catch (Exception e)
{
// Catch exceptions so our loop doesn't die whenever there is an error
Debug.LogException(e);
}
}
}

private void CheckForPortalCrossing()
{
// Clear removal queue

objectsInPortalToRemove.Clear();

// Check every touching object

foreach (var portalableObject in objectsInPortal)
{
// If portalable object has been destroyed, remove it immediately

if (portalableObject == null)
{
objectsInPortalToRemove.Add(portalableObject);
continue;
}

// Check if portalable object is behind the portal using Vector3.Dot (dot product)
// If so, they have crossed through the portal.

var pivot = portalableObject.transform;
var directionToPivotFromTransform = pivot.position - transform.position;
directionToPivotFromTransform.Normalize();
var pivotToNormalDotProduct = Vector3.Dot(directionToPivotFromTransform, normalVisible.forward);
if (pivotToNormalDotProduct > 0) continue;

// Warp object

var newPosition = TransformPositionBetweenPortals(this, targetPortal, portalableObject.transform.position);
var newRotation = TransformRotationBetweenPortals(this, targetPortal, portalableObject.transform.rotation);
portalableObject.transform.SetPositionAndRotation(newPosition, newRotation);
portalableObject.OnHasTeleported(this, targetPortal, newPosition, newRotation);

// Object is no longer touching this side of the portal

objectsInPortalToRemove.Add(portalableObject);
}

// Remove all objects queued up for removal

foreach (var portalableObject in objectsInPortalToRemove)
{
objectsInPortal.Remove(portalableObject);
}
}

private void LateUpdate()
{
// Calculate portal camera position and rotation

var virtualPosition = TransformPositionBetweenPortals(this, targetPortal, mainCamera.transform.position);
var virtualRotation = TransformRotationBetweenPortals(this, targetPortal, mainCamera.transform.rotation);

// Position camera

portalCamera.transform.SetPositionAndRotation(virtualPosition, virtualRotation);

// Calculate projection matrix

var clipThroughSpace =
Matrix4x4.Transpose(Matrix4x4.Inverse(portalCamera.worldToCameraMatrix))
* targetPortal.vectorPlane;

// Set portal camera projection matrix to clip walls between target portal and portal camera
// Inherits main camera near/far clip plane and FOV settings

var obliqueProjectionMatrix = mainCamera.CalculateObliqueMatrix(clipThroughSpace);
portalCamera.projectionMatrix = obliqueProjectionMatrix;
}

private void OnTriggerEnter(Collider other)
{
var portalableObject = other.GetComponent<PortalableObject>();
if (portalableObject)
{
objectsInPortal.Add(portalableObject);
}
}

private void OnTriggerExit(Collider other)
{
var portalableObject = other.GetComponent<PortalableObject>();
if (portalableObject)
{
objectsInPortal.Remove(portalableObject);
}
}

private void OnDestroy()
{
// Release render texture from GPU

viewthroughRenderTexture.Release();

// Destroy cloned material and render texture

Destroy(viewthroughMaterial);
Destroy(viewthroughRenderTexture);
}
}

PortalableObject.cs

using UnityEngine;

public class PortalableObject : MonoBehaviour
{
public delegate void HasTeleportedHandler(Portal sender, Portal destination, Vector3 newPosition, Quaternion newRotation);
public event HasTeleportedHandler HasTeleported;

public void OnHasTeleported(Portal sender, Portal destination, Vector3 newPosition, Quaternion newRotation)
{
HasTeleported?.Invoke(sender, destination, newPosition, newRotation);
}
}

NearClipPlane.cs

using UnityEngine;

public class NearClipPlane : MonoBehaviour
{
public float nearClipPlane = 0.0001f;

private void Start()
{
GetComponent<Camera>().nearClipPlane = nearClipPlane;
}
}

--

--