Multiple Recursive Portals and AI In Unity Part 8: AI visibility checks

Lim Ding Wen
11 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 an AI that is able to navigate through portals. In this final tutorial, we will create another AI that is able to aim at the player and know if the player is visible, even through recursive portals.

This tutorial is made with Unity 2019.3.7f1. It relies on the final project from the previous tutorial, Part 7. 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 7 Final: The previous project, includes Part 7 premade
  • Part 8 Starter: Starter kit, includes Part 7 and the AI with aiming premade
  • Part 8 Final: Final project, includes Part 8 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 aiming yourself, you can grab it from the starter kit from the links above.

AI creation

We’re just going to make a simple cube that will continuously aim at the player.

First, create a cube and place it somewhere within the scene. Create a second cube as a child of the first cube and assign a material to it, making it look like a “nose”. Place the second cube somewhere on the positive Z-axis, as that is where “forward” is. Remove the BoxCollider from both cubes.

Create a script named AiAimController.cs and attach it to your cube. Use this code, which simply references a target transform and looks at it every tick.

using UnityEngine;

public class AiAimController : MonoBehaviour
{
public Transform target;

private void FixedUpdate()
{
transform.LookAt(target);
}
}

Create an aiming target on the player…

… and assign it to the AI.

Click play!

The AI tracks your position nicely. However, watch what happens when I move into the green room. (I moved the green room to make the problem more obvious.)

As you can see, while the AI manages to track you in the same room, when you go through the portal the AI will no longer be tracking you accurately. That is what the rest of the tutorial is intending to solve.

Let’s begin!

Portal theory

When we do aiming in a normal space, there is only a single position our target can be, so aiming is trivial. However, when aiming with portals, there are potentially infinite copies of our target. Imagine being in an infinite hallway with your target. Which version of the target should you aim at? How do you even know where the versions are in the first place?

We’ll want to start by building a list of apparent positions. These are world positions that the target appears to be from the AI’s point of view. We obtain the apparent positions by “welding” the portals together and then using the resulting target position, then repeating it for all the possible portal permutations that lead us to the target.

However, we don’t want just any apparent position, we want apparent positions that the AI can actually see. In normal games, not implementing visibility checks simply means that the AI may try to shoot you through walls. However, with non-euclidean environments, it may result in very obviously wrong behaviour.

For example in an overlapping space such as an 8-corner room, the apparent position of a target may be right next to an AI, even though the target is not actually in the same space, causing the AI to shoot at seemingly nothing. In this scenario drawn below, the AI will start shooting northwards, even though in reality there is no one there in the red room.

One of the ways to solve this problem is to simply check for visibility. In this case, the AI isn’t able to see the target, so the AI won’t try to shoot at it. Visibility checking is essential for aiming to work correctly.

The way to check for visibility is to do the same as normal games do; raycast towards the target, and if it hits the target, the target is considered visible. However, in our case, we’ll need to swap out the usual raycasting with the recursive raycast function we implemented in Part 3.

Since there might be unlimited apparent positions, we’ll need to stop checking at a certain point. A first defence we can use is a simple recursion limit. We can optimize that further by using a breadth-first search and then stopping the search when we find at least X visible apparent positions.

Implementation

Open up AiAimingController.cs and add in these variables. The layer mask defines what layers our visibility check will check against. We’ll also be able to control how many recursions we will check for apparent positions, and how many apparent positions are needed to stop the check prematurely.

public LayerMask layerMask;
public int maxRecursions;
public int maxApparentPositions;

Next, we’ll have 2 temporary lists that we’ll be using. AimingTargetApparentPositions is a list that will be used to build the full array of apparent positions, while AimingPortalChainQueue will be used in the breadth-first search, the lists containing a chain of portals that we’ll “weld” together to find an apparent position of the target.

private static readonly List<TargetApparentPosition> AimingTargetApparentPositions = new List<TargetApparentPosition>();
private static readonly Queue<List<Portal>> AimingPortalChainQueue = new Queue<List<Portal>>(); // Chain of SOURCE portals.

Here is the nested class definition for TargetApparentPosition.

    public class TargetApparentPosition
{
public Vector3 ApparentPosition;
public Vector3 AimDirection;
}

Let’s start by caching the entire scene’s portal occlusion volumes. We’re going to need this later since the AI needs to know which occlusion volume it is in.

private PortalOcclusionVolume[] occlusionVolumes;

private void Start()
{
occlusionVolumes = FindObjectsOfType<PortalOcclusionVolume>();
}

Let’s start by creating the function we’ll use to find the best apparent position; the apparent position and direction our AI should be aiming at.

We’ll start off by defining the origin and target absolute positions. Next, we’ll accept a tag that our visibility checker will look for. The layer mask aids in this as well, allowing our visibility checker to not get blocked by small objects or hitboxes. Next, we’ll accept an occlusion volume; this is the occlusion volume that our origin is currently in. This is needed so that we will know which portals can be used to start the search for apparent positions. Finally, we’ll accept the max recursions and max apparent positions, explained earlier.

public static TargetApparentPosition FindBestApparentPosition(
Vector3 origin,
Vector3 target,
string targetTag,
LayerMask layerMask,
PortalOcclusionVolume occlusionVolume,
int maxRecursions,
int maxApparentPositions)
{

Let’s start by clearing our temp lists.

AimingPortalChainQueue.Clear();
AimingTargetApparentPositions.Clear();

Next, we’ll start the breadth-first search. Recall that AimingPortalChainQueue will contain lists which contain a chain of portals that we “weld” together to find an apparent position. We’ll start by having no portals to chain together, in other words, just finding the target’s normal position, in case the target is in the same room as the AI already.

// Breadth first search

AimingPortalChainQueue.Enqueue(new List<Portal>());
while (AimingPortalChainQueue.Count > 0)
{
var currentChain = AimingPortalChainQueue.Dequeue();

Let’s start by calculating the apparent position. We can do this by simply transforming the target’s position from the target’s portal towards our portal, recursively. The order doesn’t matter here so a foreach loop is good enough.

// Calculate apparent location

var targetApparentPosition = target;
foreach (var portal in currentChain)
targetApparentPosition = Portal.TransformPositionBetweenPortals(portal.targetPortal, portal,
targetApparentPosition);

We now have an apparent position, the position that the target appears to look like from the AI’s point of view. Using this, we can easily calculate an aim direction using normal Vector math.

var aimDirection = targetApparentPosition - origin;
aimDirection.Normalize();

We can use this aim direction to check for visibility via a recursive raycast. The thing of interest here is the maximum recursion count, which is currentChain.Count. If the chain of portals to calculate this apparent position is 2 portals deep, for example, there should not be more than 2 recursions in our visibility check. Similarly, if there are no portals used to calculate this apparent position (i.e. normal space), there should be no recursions at all.

// Calculate visibility
// If visible, add to target apparent positions

if (Portal.RaycastRecursive(
origin,
aimDirection,
currentChain.Count,
null,
(x, y) => layerMask,
out var hitInfo))
{
if (hitInfo.collider.CompareTag(targetTag))
{
AimingTargetApparentPositions.Add(new TargetApparentPosition
{
ApparentPosition = targetApparentPosition,
AimDirection = aimDirection
});
}
}

Now that we may have added a new apparent position, let’s check if we have enough to stop checking.

// Return if enough for an accurate heuristic

if (maxApparentPositions > 0)
if (AimingTargetApparentPositions.Count >= maxApparentPositions)
break;

If not, we’ll continue searching. First, check if we have reached the number of max recursions by looking at the chain count.

We’ll check deeper recursions by adding deeper chains of portals, using the visible portals of the last portal in the chain. If we are just starting and there are no portals in the chain yet, we’ll use the AI occlusion volume’s portals instead. We also check if occlusionVolume is null, if so, we don’t add any.

// Continue search; add to queue

if (currentChain.Count >= maxRecursions) continue;
foreach (var visiblePortal in currentChain.Count > 0 ? currentChain[currentChain.Count-1].visiblePortals : occlusionVolume == null ? new Portal[0] : occlusionVolume.portals)
AimingPortalChainQueue.Enqueue(new List<Portal>(currentChain) { visiblePortal });

Close the while loop. Next, we’ll determine which of the recorded apparent positions is the one the AI should look at. For now, we’ll just find the closest one. If there are no apparent positions (i.e. not visible), it will return null.

}

// Use heuristic (closest apparent) to find which of the apparent positions should the AI shoot at

TargetApparentPosition bestApparentPosition = null;
var minDistance = Mathf.Infinity;
foreach (var aimingTargetApparentPosition in AimingTargetApparentPositions)
{
var distance = Vector3.Distance(aimingTargetApparentPosition.ApparentPosition, origin);
if (distance >= minDistance) continue;
minDistance = distance;
bestApparentPosition = aimingTargetApparentPosition;
}

return bestApparentPosition;

Remove all code in FixedUpdate. Let’s start by finding the occlusion volume the AI is in.

private void FixedUpdate()
{
PortalOcclusionVolume currentOcclusionVolume = null;
foreach (var occlusionVolume in occlusionVolumes)
{
if (occlusionVolume.collider.bounds.Contains(transform.position))
{
currentOcclusionVolume = occlusionVolume;
break;
}
}

You may notice that this is very similar to the code used in PortalRenderer.cs, and in a real project, I would recommend DRY-ing your code into a PortalOcclusionVolumeManager. However, since this is a tutorial, this is duplicated for the sake of completeness and simplicity.

Call the function that we created earlier to find the best apparent position we should be aiming at.

var bestApparentPosition = FindBestApparentPosition(
transform.position,
target.position,
"Player Visibility Checker",
layerMask,
currentOcclusionVolume,
maxRecursions,
maxApparentPositions
);

If it’s not null, it means that the target is visible, and we shall look at it.

if (bestApparentPosition != null)
{
transform.LookAt(bestApparentPosition.ApparentPosition);
}

Let’s first create the player’s visibility checker. Create a new child GameObject on the Player, then create add to it a Capsule Collider. Make the Capsule Collider a trigger. Next, create a new layer named “Visibility Checker” and a new tag named “Player Visibility Checker”, and assign both to the object.

For the Ai Aiming Controller, we’ll set the layer mask Default (so that the recursive raycasting may touch portals) and the Visibility Checker. Set the Max Recursions and Max Apparent Positions to a small number, e.g. 2.

Click play!

As you can see, the aiming controller now manages to follow you through portals. Look at the shadow to see it clearer. This is a pretty simple example, though. Try more complex levels, with overlapping spaces and many recursive portals, to really test your implementation and find any bugs.

Due to the simplicity of this scene, the performance impact of this is not big. However, in more complex scenes with more portals, the function to calculate the best apparent position can take up to 1 millisecond to process! Multiply that by 9 AI bots checking for other 9 AI bots, it results in 81 milliseconds every tick spent just on visibility checks. Obviously, this is not good.

The workaround used in Quantum Tournament is to separate out the visibility checks, only 3 checks per frame. As a bonus, this gives the AI a “human reaction time” of 250ms.

Closing

In this final tutorial, you have learnt how to create an AI that can follow you through portals and also check if you are visible to them with portal support. This can be extended to create shooting behaviour, for example.

Thank you for following this tutorial! I hope this has been helpful to you.

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

AiAimController.cs

using System.Collections.Generic;
using UnityEngine;

public class AiAimController : MonoBehaviour
{
public Transform target;

public LayerMask layerMask;
public int maxRecursions;
public int maxApparentPositions;

private static readonly List<TargetApparentPosition> AimingTargetApparentPositions = new List<TargetApparentPosition>();
private static readonly Queue<List<Portal>> AimingPortalChainQueue = new Queue<List<Portal>>(); // Chain of SOURCE portals.

private PortalOcclusionVolume[] occlusionVolumes;

private void Start()
{
occlusionVolumes = FindObjectsOfType<PortalOcclusionVolume>();
}

private void FixedUpdate()
{
PortalOcclusionVolume currentOcclusionVolume = null;
foreach (var occlusionVolume in occlusionVolumes)
{
if (occlusionVolume.collider.bounds.Contains(transform.position))
{
currentOcclusionVolume = occlusionVolume;
break;
}
}

var bestApparentPosition = FindBestApparentPosition(
transform.position,
target.position,
"Player Visibility Checker",
layerMask,
currentOcclusionVolume,
maxRecursions,
maxApparentPositions
);
if (bestApparentPosition != null)
{
transform.LookAt(bestApparentPosition.ApparentPosition);
}
}

public static TargetApparentPosition FindBestApparentPosition(
Vector3 origin,
Vector3 target,
string targetTag,
LayerMask layerMask,
PortalOcclusionVolume occlusionVolume,
int maxRecursions,
int maxApparentPositions)
{
AimingPortalChainQueue.Clear();
AimingTargetApparentPositions.Clear();

// Breadth first search

AimingPortalChainQueue.Enqueue(new List<Portal>());
while (AimingPortalChainQueue.Count > 0)
{
var currentChain = AimingPortalChainQueue.Dequeue();

// Calculate apparent location

var targetApparentPosition = target;
foreach (var portal in currentChain)
targetApparentPosition = Portal.TransformPositionBetweenPortals(portal.targetPortal, portal,
targetApparentPosition);

var aimDirection = targetApparentPosition - origin;
aimDirection.Normalize();

// Calculate visibility
// If visible, add to target apparent positions

if (Portal.RaycastRecursive(
origin,
aimDirection,
currentChain.Count,
null,
(x, y) => layerMask,
out var hitInfo))
{
if (hitInfo.collider.CompareTag(targetTag))
{
AimingTargetApparentPositions.Add(new TargetApparentPosition
{
ApparentPosition = targetApparentPosition,
AimDirection = aimDirection
});
}
}

// Return if enough for an accurate heuristic

if (maxApparentPositions > 0)
if (AimingTargetApparentPositions.Count >= maxApparentPositions)
break;

// Continue search; add to queue

if (currentChain.Count >= maxRecursions) continue;
foreach (var visiblePortal in currentChain.Count > 0 ? currentChain[currentChain.Count-1].visiblePortals : occlusionVolume == null ? new Portal[0] : occlusionVolume.portals)
AimingPortalChainQueue.Enqueue(new List<Portal>(currentChain) { visiblePortal });
}

// Use heuristic (closest apparent) to find which of the apparent positions should the AI shoot at

TargetApparentPosition bestApparentPosition = null;
var minDistance = Mathf.Infinity;
foreach (var aimingTargetApparentPosition in AimingTargetApparentPositions)
{
var distance = Vector3.Distance(aimingTargetApparentPosition.ApparentPosition, origin);
if (distance >= minDistance) continue;
minDistance = distance;
bestApparentPosition = aimingTargetApparentPosition;
}

return bestApparentPosition;
}

public class TargetApparentPosition
{
public Vector3 ApparentPosition;
public Vector3 AimDirection;
}
}

--

--