Multiple Recursive Portals and AI In Unity Part 7: AI navigation

Lim Ding Wen
18 min readMay 30, 2020

--

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

In the last tutorial, we created a cloning system so that our player wouldn’t be cut off whenever they passed a portal. In doing so, we managed to create completely seamless portals. In this tutorial, we’ll discuss how we can integrate AI into our portal-based environments with navigation.

This tutorial is made with Unity 2019.3.7f1. It relies on the final project from the previous tutorial, Part 6. 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 6 Final: The previous project, includes Part 6 premade
  • Part 7 Starter: Starter kit, includes Part 6 and the AI agent with following behaviour premade
  • Part 7 Final: Final project, includes Part 7 premade

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

Preparation

If you don’t want to create the AI agent with following behaviour yourself, you can grab it from the starter kit from the links above.

Creating the AI agent

First, create a new capsule using GameObject > 3D Objects > Capsule. Place it within the scene, and make the Capsule Collider a trigger. Rename it to “AI”. Add a kinematic Rigidbody since we have a moving trigger. Add the Nav Mesh Agent component to it which should automatically fit.

Why are we using a NavMeshAgent? Can we do the same thing with directly calculating the path and having a custom controller follow that path?

The first reason is that using NavMeshAgent is simple enough for this tutorial which just needs basic pathfinding movement.

Our tutorial will be using OffMeshLinks to let the AI acknowledge paths through portals. Now, it is definitely possible to use NavMesh.CalculatePath and have it work with OffMeshLinks. However, do note that you can only check OffMeshLinks’ data in a generated path if you use NavMeshAgent.

This means that if you have other types of OffMeshLinks, for example for jumping across gaps, you’ll need to use a NavMeshAgent to know what kind of link it is.

That’s why Quantum Tournament uses NavMeshAgents despite the game using custom character physics and is also the second reason why I’m focusing on NavMeshAgents in this tutorial. It is entirely possible to use custom character movement with Agents, it just takes some work with NavMeshAgent.desiredVelocity etc.

Next, open up the Navigation tab by going to Window > AI > Navigation. Simply press the Bake button to bake the NavMesh. You should see the blue NavMesh show up. If it doesn’t, make sure your level geometry is set as Static, and Bake the NavMesh again.

Create a new script named AiController.cs and attach the component to the AI object. Copy and paste the following script, which simply accepts a destination through a public function, and sets the destination of the NavMesh Agent.

using UnityEngine;
using UnityEngine.AI;

public class AiController : MonoBehaviour
{
private NavMeshAgent agent;

private void Start()
{
agent = GetComponent<NavMeshAgent>();
}

public void Goto(Vector3 destination)
{
agent.SetDestination(destination);
}
}

Next, we’ll need to modify PlayerController.cs to allow it to control the AI. Add this line into the shooting logic:

hitInfo.collider.SendMessageUpwards("OnShoot", hitInfo, SendMessageOptions.DontRequireReceiver);
FindObjectOfType<AiController>().Goto(hitInfo.point);

Click play!

As you can see, you are now able to click anywhere and the AI will try its best to reach that point. However, it will not acknowledge or go through portals yet. That is what the rest of the tutorial is about.

As a side note, we aren’t going to make the AI cloneable, animated or anything in this tutorial, just to keep things simple, but you can totally do that if you wanted to.

Let’s begin!

Generating OffMeshLinks

OffMeshLinks are links between 2 points on a NavMesh and is what we’ll be using to connect our portals through pathfinding. To make sure that our AI can travel through the portal at seemingly any angle, we’ll be placing many OffMeshLinks automatically at a certain distance interval.

Open up Portal.cs. First, we’ll need to define a few variables. We’ll let the level designer change the resolution of the OffMeshLinks. We’ll also need to have 2 Transforms for reference; the OffMeshLinks will be generated in a straight line between these 2 references. Then, we’ll have the Unity NavMesh Area that our OffMeshLinks will belong to, which defines things such as the cost of moving through the OffMeshLink. Finally, we’ll have a list of PortalOffMeshLinks that will be used to help connect portals to one another.

public float offMeshLinkResolution = 0.2f;
public Transform offMeshLinkRef1;
public Transform offMeshLinkRef2;
public int offMeshLinkArea;
private readonly List<PortalOffMeshLink> offMeshLinks = new List<PortalOffMeshLink>();

The definition of the struct PortalOffMeshLink is as below. As you can see, it simply contains the transform of a generated OffMeshLink.

private struct PortalOffMeshLink
{
public Transform RefTransform;
}

For the OffMeshLink generation to work, we’ll need 2 stages. The first stage, done in Awake, will be to generate the transforms and store them in a list. The second stage, done in Start, will be to generate the OffMeshLinks using the transforms generated by this portal and the target portal in the first stage.

Create an Awake function. Here, we’ll first calculate the direction from Ref 1 to Ref 2, and calculate the distance between the Refs.

private void Awake()
{
// Generate OffMeshLinks

var directionToRef2 = offMeshLinkRef2.position - offMeshLinkRef1.position;
var distanceToGenerate = directionToRef2.magnitude;
directionToRef2.Normalize();

Next, we’ll loop through the distance, incrementing the current distance at the specified interval until we reach the distance between the Refs. This constrains the generated Transforms to be between the Refs.

Each Transform’s position is simply calculated from Ref 1, towards the direction of Ref 2 multiplied with the current distance. We’ll then give it an auto-generated name, parent it to the Portal, and add it to the list.

for (var currentDistance = 0f; currentDistance <= distanceToGenerate; currentDistance += offMeshLinkResolution)
{
var newPosition = offMeshLinkRef1.position + directionToRef2 * currentDistance;
var newTransform = new GameObject("[AUTO] OffMeshLink Transform").transform;
newTransform.parent = transform;
newTransform.position = newPosition;

offMeshLinks.Add(new PortalOffMeshLink()
{
RefTransform = newTransform
});
}

It’s now time to do the second stage, generating the actual OffMeshLinks. We’ll loop through the generated transforms, and create an OffMeshLink for each one. For each OffMeshLink, the end transform will be the target portal’s counterpart, calculated using array indexes. Since the target portal should be the same scale, the number of generated transforms should be the same, though you might want to add in error checking there.

We’ll finish off by setting some properties for the OffMeshLinks, such as being unidirectional (the target portal will generate its OffMeshLinks back), activated, and of course the NavMesh Area it belongs to.

private void Start()
{
// Finish OffMeshLink generation

for (var i = 0; i < offMeshLinks.Count; i++)
{
var offMeshLink = offMeshLinks[i];

var newLink = offMeshLink.RefTransform.gameObject.AddComponent<OffMeshLink>();
newLink.startTransform = offMeshLink.RefTransform;
newLink.endTransform = targetPortal.offMeshLinks[offMeshLinks.Count - 1 - i].RefTransform;
newLink.biDirectional = false;
newLink.costOverride = -1;
newLink.autoUpdatePositions = false;
newLink.activated = true;
newLink.area = offMeshLinkArea;
}

Open the Navigation tab in Unity again. Go to the Areas tab and create a new area named “Portal OffMeshLink” (the name is not important, the number is), and assign it a cost of 1. We’ll bump up the other areas by a factor of 100, to make portals look almost instantaneous to the pathfinder.

Why can’t we just set the area’s cost to 0?

Unity will give a warning if you do. The pathfinder that Unity uses requires a cost of at least 1. To imitate a “no-cost route” for our portals, we’ll use a cost of 1 and a very high cost for the normal walkable surfaces. In this case, a factor of 100.

Open the Portal prefab in Unity. Create 2 transforms named “OffMeshLink Ref 1” and “OffMeshLink Ref 2”. These 2 transforms will define the line in which we will generate the OffMeshLinks on.

This is what we want the references to be. On opposite sides of the portal, near the ground (Unity will detect the link if it’s near the NavMesh). Not pictured are the references also slightly behind the portal so that we can trick the AI into walking through the portal simply by using the links.

This is how the transform settings look like. OffMeshLink Ref 2 will have the exact same settings, except for Position X, which should be -0.48 instead.

Now go to the Portal component. Don’t forget to assign the reference transforms. You can play around with the resolution. By default, it’s 0.2, which means an OffMeshLink will be generated every 0.2 meters. Finally, we’ll set the area number that we created earlier in Navigation.

Click play!

Use the Scene View and open up the Navigation tab to see this view. If you see something like this, you’re golden!

Making the AI go through portals

We’ll first prepare the NavMeshAgent component to work with portals. The first thing we need to do is to disable Auto Traverse Off Mesh Links. This stops Unity from automatically moving the Agent between portals (hilariously).

Next, our NavMeshAgent is currently a bit too fat. Remember that we’re trying to trick the NavMeshAgent into walking through the portal using the OffMeshLinks. However, due to the radius of our agent, it ends up stopping short of the portal, as the radius of the agent has already touched the link.

There are 2 solutions to this. The simple solution is to simply make the NavMeshAgent thinner until it can go through the portal reliably. The second solution is kind of a backup solution if the NavMeshAgent does get stuck when going through the portal.

We’re mostly going to focus on the first solution since that’s all our simple scene needs, but I’ll also provide the code for the second solution in case you need it as a backup. Quantum Tournament uses both solutions.

First, is there a formula to make the NavMeshAgent go through portals reliably? Yes, there is.

Let A be the distance from the portal to the OffMeshLinks
Let B be the NavMeshAgent's radius
Let C be a buffer, for example 0.5
As long as
A >= 2B + C
The NavMeshAgent will go through the portal reliably.

Recall that our OffMeshLink’s Z position is 0.45. A good NavMeshAgent radius, therefore, will be 0.2. Set the AI’s NavMeshAgent radius to 0.2.

Next, go to the Navigation tab and go to the Bake tab. Change the Agent Radius to 0.2 and press Bake again.

It’s now time to make the AI go through portals! Open up AiController.cs and add in a reference to the PortalableObject and a nullable Vector3 for the current destination the AI is trying to get to. It’s nullable since the AI may not have a current destination in mind, such as when spawned.

private PortalableObject portalableObject;
private Vector3? currentDestination;

Subscribe to the PortalableObject’s HasTeleported event. Remember to unsubscribe in OnDestroy.

private void Start()
{
agent = GetComponent<NavMeshAgent>();
portalableObject = GetComponent<PortalableObject>();
portalableObject.HasTeleported += PortalableObjectOnHasTeleported;

}

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

When the object teleports, we’ll need to run some custom logic. First, we’ll warp the agent to the new position, and we’ll print a warning if we can’t.

private void PortalableObjectOnHasTeleported(Portal sender, Portal destination, Vector3 newPosition, Quaternion newRotation)
{
if (!agent.Warp(newPosition))
Debug.LogWarning($"Warp failed for {gameObject.name} NavMeshAgent.");

Next, we’ll recalculate the path whenever we teleport. If we are currently going towards a destination, we’ll calculate a new path and use it.

if (currentDestination != null)
{
var path = new NavMeshPath();
agent.CalculatePath(currentDestination.Value, path);
agent.SetPath(path);
}

Why don’t you just use NavMeshAgent.SetDestination?

SetDestination is asynchronous and may take multiple frames to finish. This is usually to spread the load out on the system, so multiple NavMeshAgents trying to pathfind at the same time won’t drop the framerate.

However, in our case, it’s more important that the path is calculated instantly, in a single frame, so that we maintain the illusion of continuity between portals. The calculation process is usually fast enough, less than a millisecond, so it wouldn’t usually give us any performance issues as well.

By using CalculatePath, which completes synchronously, we can guarantee that the AI will continue moving with no visible pauses.

In the Goto function, remember to also set the current destination.

public void Goto(Vector3 destination)
{
currentDestination = destination;
agent.SetDestination(destination);
}

Click play!

Why does the AI sometimes not take the shortest path, such as using a side OffMeshLink instead of using a link right beside it?

The pathfinding algorithm used by Unity prefers speed over complete accuracy. It is good enough for most cases, but for our case, it does produce some weird behaviour when an AI would walk a weird path to go through a portal. This is something that I’m not quite sure how to solve yet, though.

Your AI should be able to traverse through portals now. However, there are 2 things in this gif that demonstrate that there is still work to be done to make it truly seamless. They are out of the scope of this tutorial, however.

The first step is to make cloning work on the AI. Look at the previous tutorial, part 6, for more information.

The second step is to make the AI not stop when going through portals. This is due to NavMeshAgent’s acceleration parameter. You can solve this by having custom character movement, which you should implement anyway, where you can preserve the momentum of the character as it goes through portals.

When using custom character movement with NavMeshAgent, you can use NavMeshAgent.desiredVelocity to drive the movement of the AI, since desiredVelocity is not affected by acceleration, and therefore not affected by going through portals.

If you just want to use the NavMeshAgent for movement, you can simply set the acceleration to a very high number (e.g. 1000) instead.

Just in case NavMeshAgent gets stuck on a portal

If your NavMeshAgent manages to get itself stuck by touching the OffMeshLink before actually going through the portal, you can use this snippet of code to get it unstuck.

Basically, every tick, we check if the agent is on an OffMeshLink. Here, you may want to check for the OffMeshLink’s tag if you have other OffMeshLinks in your project.

First, we calculate the direction from the AI’s current position towards the OffMeshLink’s starting position. We reproject it to the Y plane to make sure our AI stays level, but you don’t need to do this if you’re using a custom character movement script that handles gravity.

Next, we normalize it and multiply the direction with the speed of the agent. This can be replaced by the speed of your AI or your character. Finally, we make it frame independent by multiplying fixedDeltaTime.

private void FixedUpdate()
{
if (agent.isOnOffMeshLink)
{
// Move character towards OffMeshLink start point
// Should only really be used if the AI reaches the link without finishing going through the portal.

transform.Translate(Vector3.ProjectOnPlane(agent.currentOffMeshLinkData.startPos - transform.position, Vector3.up).normalized * (agent.speed * Time.fixedDeltaTime));
}
}

Closing

In this tutorial, you have learnt how to pathfind through portals and how to use a NavMeshAgent to navigate through the portals. Combining these techniques with Object Cloning and a custom movement script will go a long way in creating more seamless AI navigation.

In the next and final tutorial, we will discuss how to let bots aim at you and check if you are visible to them, even through portals.

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

AiController.cs

using UnityEngine;
using UnityEngine.AI;

public class AiController : MonoBehaviour
{
private NavMeshAgent agent;
private PortalableObject portalableObject;
private Vector3? currentDestination;

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

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

private void PortalableObjectOnHasTeleported(Portal sender, Portal destination, Vector3 newPosition, Quaternion newRotation)
{
if (!agent.Warp(newPosition))
Debug.LogWarning($"Warp failed for {gameObject.name} NavMeshAgent.");

if (currentDestination != null)
{
var path = new NavMeshPath();
agent.CalculatePath(currentDestination.Value, path);
agent.SetPath(path);
}
}

private void FixedUpdate()
{
if (agent.isOnOffMeshLink)
{
// Move character towards OffMeshLink start point
// Should only really be used if the AI reaches the link without finishing going through the portal.

transform.Translate(Vector3.ProjectOnPlane(agent.currentOffMeshLinkData.startPos - transform.position, Vector3.up).normalized * (agent.speed * Time.fixedDeltaTime));
}
}

public void Goto(Vector3 destination)
{
currentDestination = destination;
agent.SetDestination(destination);
}
}

Portal.cs

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

public class Portal : MonoBehaviour
{
public Portal targetPortal;

public Transform normalVisible;
public Transform normalInvisible;

public Renderer viewthroughRenderer;
private Material viewthroughMaterial;

private Camera mainCamera;

public Plane plane;
private Vector4 vectorPlane;

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

public Portal[] visiblePortals;

public Texture viewthroughDefaultTexture;

public int maxRecursionsOverride = -1;

public float offMeshLinkResolution = 0.2f;
public Transform offMeshLinkRef1;
public Transform offMeshLinkRef2;
public int offMeshLinkArea;
private readonly List<PortalOffMeshLink> offMeshLinks = new List<PortalOffMeshLink>();

public bool ShouldRender(Plane[] cameraPlanes) =>
viewthroughRenderer.isVisible &&
GeometryUtility.TestPlanesAABB(cameraPlanes,
viewthroughRenderer.bounds);

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

public static Vector3 TransformDirectionBetweenPortals(Portal sender, Portal target, Vector3 position)
{
return
target.normalInvisible.TransformDirection(
sender.normalVisible.InverseTransformDirection(position));
}

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

private void Awake()
{
// Generate OffMeshLinks

var directionToRef2 = offMeshLinkRef2.position - offMeshLinkRef1.position;
var distanceToGenerate = directionToRef2.magnitude;
directionToRef2.Normalize();

for (var currentDistance = 0f; currentDistance <= distanceToGenerate; currentDistance += offMeshLinkResolution)
{
var newPosition = offMeshLinkRef1.position + directionToRef2 * currentDistance;
var newTransform = new GameObject("[AUTO] OffMeshLink Transform").transform;
newTransform.parent = transform;
newTransform.position = newPosition;

offMeshLinks.Add(new PortalOffMeshLink()
{
RefTransform = newTransform
});
}
}

private void Start()
{
// Finish OffMeshLink generation

for (var i = 0; i < offMeshLinks.Count; i++)
{
var offMeshLink = offMeshLinks[i];

var newLink = offMeshLink.RefTransform.gameObject.AddComponent<OffMeshLink>();
newLink.startTransform = offMeshLink.RefTransform;
newLink.endTransform = targetPortal.offMeshLinks[offMeshLinks.Count - 1 - i].RefTransform;
newLink.biDirectional = false;
newLink.costOverride = -1;
newLink.autoUpdatePositions = false;
newLink.activated = true;
newLink.area = offMeshLinkArea;
}

// Get cloned material

viewthroughMaterial = viewthroughRenderer.material;

// Cache the main camera

mainCamera = Camera.main;

// Generate bounding plane

plane = new Plane(normalVisible.forward, transform.position);
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);
}
}

public static bool RaycastRecursive(
Vector3 position,
Vector3 direction,
int maxRecursions,
Action<int> customBeforeRaycast,
Func<Portal, int, LayerMask> layerMaskSelector,
out RaycastHit hitInfo)
{
return RaycastRecursiveInternal(position,
direction,
maxRecursions,
customBeforeRaycast,
layerMaskSelector,
out hitInfo,
0,
null,
null);
}

private static bool RaycastRecursiveInternal(
Vector3 position,
Vector3 direction,
int maxRecursions,
Action<int> customBeforeRaycast,
Func<Portal, int, LayerMask> layerMaskSelector,
out RaycastHit hitInfo,
int currentRecursion,
GameObject ignoreObject,
Portal lastTraversedPortal)
{
// Ignore a specific object when raycasting.
// Useful for preventing a raycast through a portal from hitting the target portal from the back,
// which makes a raycast unable to go through a portal since it'll just be absorbed by the target portal's trigger.

var ignoreObjectOriginalLayer = 0;
if (ignoreObject)
{
ignoreObjectOriginalLayer = ignoreObject.layer;
ignoreObject.layer = 2; // Ignore raycast
}

// Custom before raycast

customBeforeRaycast?.Invoke(currentRecursion);

// Shoot raycast

var raycastHitSomething = Physics.Raycast(
position,
direction,
out var hit,
Mathf.Infinity,
layerMaskSelector(lastTraversedPortal, currentRecursion));

// Reset ignore

if (ignoreObject)
ignoreObject.layer = ignoreObjectOriginalLayer;

// If no objects are hit, the recursion ends here, with no effect

if (!raycastHitSomething)
{
hitInfo = new RaycastHit(); // Dummy
return false;
}

// If the object hit is a portal, recurse, unless we are already at max recursions

var portal = hit.collider.GetComponent<Portal>();
if (portal)
{
if (currentRecursion >= maxRecursions)
{
hitInfo = new RaycastHit(); // Dummy
return false;
}

// Continue going down the rabbit hole...

return RaycastRecursiveInternal(
TransformPositionBetweenPortals(portal, portal.targetPortal, hit.point),
TransformDirectionBetweenPortals(portal, portal.targetPortal, direction),
maxRecursions,
customBeforeRaycast,
layerMaskSelector,
out hitInfo,
currentRecursion + 1,
portal.targetPortal.gameObject,
portal);
}

// If the object hit is not a portal, then congrats! We stop here and report back that we hit something.

hitInfo = hit;
return true;
}

public void RenderViewthroughRecursive(
Vector3 refPosition,
Quaternion refRotation,
out RenderTexturePool.PoolItem temporaryPoolItem,
out Texture originalTexture,
out int debugRenderCount,
Camera portalCamera,
int currentRecursion,
int maxRecursions,
LayerMask noCloneMask,
LayerMask renderCloneMask,
Portal portalNotRenderingClone)
{
debugRenderCount = 1;

// Calculate virtual camera position and rotation

var virtualPosition = TransformPositionBetweenPortals(this, targetPortal, refPosition);
var virtualRotation = TransformRotationBetweenPortals(this, targetPortal, refRotation);

// Setup portal camera for calculations

portalCamera.transform.SetPositionAndRotation(virtualPosition, virtualRotation);

// Convert target portal's plane to camera space (relative to target camera)

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

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

var obliqueProjectionMatrix = mainCamera.CalculateObliqueMatrix(targetViewThroughPlaneCameraSpace);
portalCamera.projectionMatrix = obliqueProjectionMatrix;

// Store visible portal resources to release and reset (see function description for details)

var visiblePortalResourcesList = new List<VisiblePortalResources>();

// Calculate camera planes for culling

var cameraPlanes = GeometryUtility.CalculateFrustumPlanes(portalCamera);

// Check for recursion override

var actualMaxRecursions = targetPortal.maxRecursionsOverride >= 0
? targetPortal.maxRecursionsOverride
: maxRecursions;

// Recurse if not at limit

if (currentRecursion < actualMaxRecursions)
{
foreach (var visiblePortal in targetPortal.visiblePortals)
{
if (!visiblePortal.ShouldRender(cameraPlanes)) continue;

visiblePortal.RenderViewthroughRecursive(
virtualPosition,
virtualRotation,
out var visiblePortalTemporaryPoolItem,
out var visiblePortalOriginalTexture,
out var visiblePortalRenderCount,
portalCamera,
currentRecursion + 1,
maxRecursions,
noCloneMask,
renderCloneMask,
portalNotRenderingClone);

visiblePortalResourcesList.Add(new VisiblePortalResources()
{
OriginalTexture = visiblePortalOriginalTexture,
PoolItem = visiblePortalTemporaryPoolItem,
VisiblePortal = visiblePortal
});

debugRenderCount += visiblePortalRenderCount;
}
}
else
{
foreach (var visiblePortal in targetPortal.visiblePortals)
{
visiblePortal.ShowViewthroughDefaultTexture(out var visiblePortalOriginalTexture);

visiblePortalResourcesList.Add(new VisiblePortalResources()
{
OriginalTexture = visiblePortalOriginalTexture,
VisiblePortal = visiblePortal
});
}
}

// Get new temporary render texture and set to portal's material
// Will be released by CALLER, not by this function. This is so that the caller can use the render texture
// for their own purposes, such as a Render() or a main camera render, before releasing it.

temporaryPoolItem = RenderTexturePool.Instance.GetTexture();

// Use portal camera

portalCamera.targetTexture = temporaryPoolItem.Texture;
portalCamera.transform.SetPositionAndRotation(virtualPosition, virtualRotation);
portalCamera.projectionMatrix = obliqueProjectionMatrix;
portalCamera.cullingMask = (currentRecursion == 0 && this == portalNotRenderingClone
? noCloneMask
: renderCloneMask).value;

// Render portal camera to target texture

portalCamera.Render();

// Reset and release

foreach (var resources in visiblePortalResourcesList)
{
// Reset to original texture
// So that it will remain correct if the visible portal is still expecting to be rendered
// on another camera but has already rendered its texture. Originally the texture may be overriden by other renders.

resources.VisiblePortal.viewthroughMaterial.mainTexture = resources.OriginalTexture;

// Release temp render texture

if (resources.PoolItem != null)
{
RenderTexturePool.Instance.ReleaseTexture(resources.PoolItem);
}
}

// Must be after camera render, in case it renders itself (in which the texture must not be replaced before rendering itself)
// Must be after restore, in case it restores its own old texture (in which the new texture must take precedence)

originalTexture = viewthroughMaterial.mainTexture;
viewthroughMaterial.mainTexture = temporaryPoolItem.Texture;
}

private void ShowViewthroughDefaultTexture(out Texture originalTexture)
{
originalTexture = viewthroughMaterial.mainTexture;
viewthroughMaterial.mainTexture = viewthroughDefaultTexture;
}

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()
{
// Destroy cloned material

Destroy(viewthroughMaterial);
}

private void OnDrawGizmos()
{
// Linked portals

if (targetPortal != null)
{
Gizmos.color = Color.red;
Gizmos.DrawLine(transform.position, targetPortal.transform.position);
}

// Visible portals

Gizmos.color = Color.blue;
foreach (var visiblePortal in visiblePortals)
{
Gizmos.DrawLine(transform.position, visiblePortal.transform.position);
}
}

private struct VisiblePortalResources
{
public Portal VisiblePortal;
public RenderTexturePool.PoolItem PoolItem;
public Texture OriginalTexture;
}

private struct PortalOffMeshLink
{
public Transform RefTransform;
}
}

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;

public Transform playerCamera;
private float verticalRotationAbsolute;

public LayerMask shootingMask;
public LayerMask shootingMaskNoClone;
private bool shoot;

public Animator animator;

public ParticleSystem hitParticles;

public GameObject ignoreObjectsForFirstRaycast;

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

Cursor.visible = false;
Cursor.lockState = CursorLockMode.Locked;
}

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

// Turn player (up/down)

playerCamera.localRotation = Quaternion.Euler(verticalRotationAbsolute, 0, 0);

// Move player

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

// Shoot

if (shoot)
{
var ignoreObjectsForFirstRaycastReset = false;
var ignoreObjectsForFirstRaycastOriginalLayer = ignoreObjectsForFirstRaycast.layer;
ignoreObjectsForFirstRaycast.SetLayerRecursively(2); // Ignore raycast

if (Portal.RaycastRecursive(playerCamera.position,
playerCamera.forward,
8,
currentRecursion =>
{
if (currentRecursion <= 0) return;
ignoreObjectsForFirstRaycast.SetLayerRecursively(ignoreObjectsForFirstRaycastOriginalLayer);
ignoreObjectsForFirstRaycastReset = true;
},
(lastTraversedPortal, currentRecursion) =>
PortalableObjectClone.LocalInstance.ClosestTouchingPortal == lastTraversedPortal && currentRecursion == 1
? shootingMaskNoClone
: shootingMask
,
out var hitInfo))
{
hitInfo.collider.SendMessageUpwards("OnShoot", hitInfo, SendMessageOptions.DontRequireReceiver);
FindObjectOfType<AiController>().Goto(hitInfo.point);
}

if (!ignoreObjectsForFirstRaycastReset)
ignoreObjectsForFirstRaycast.SetLayerRecursively(ignoreObjectsForFirstRaycastOriginalLayer);

shoot = false;
}

// Animate

animator.SetFloat("Locomotion X", Input.GetAxis("Horizontal"));
animator.SetFloat("Locomotion Y", Input.GetAxis("Vertical"));
}

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

verticalRotationAbsolute += Input.GetAxis("Mouse Y") * -turnSpeed;
verticalRotationAbsolute = Mathf.Clamp(verticalRotationAbsolute, -89, 89);

if (Input.GetButtonDown("Fire1")) shoot = true;
}

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

private void OnShoot(RaycastHit hitInfo)
{
Instantiate(hitParticles, hitInfo.point, Quaternion.identity);
}
}

--

--