Multiple Recursive Portals and AI In Unity Part 4: Recursive portal rendering

Lim Ding Wen
26 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 cube that could be shot through portals. In this tutorial, we’re going to kick things up a notch and allow for portals which can be seen through other portals.

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

Creating a new test chamber

Create a new ProBuilder Cube by pressing Ctrl-K. Move the cube to a suitable location on the grid. Use Face Mode in ProBuilder to stretch the room out to the dimensions: 4x4x6, as shown in the picture below. Select the entire cube and press “Invert Normals” to produce a room.

To create edge loops, go into Edge Mode, select the edge you want to divide and press “Insert Edge Loop”. Create two edge loops as shown below, in this case, the edge we are dividing is the 6 meters long one.

Use “Extrude Faces” to extrude the middle face out by 2 meters as shown below. This will form the geometry needed for 1 of our portals.

Create another edge loop in the other direction, and extrude the face out by 2 meters as shown below. This is for the other side of the portal.

It’s time to start work on the second room. We’ll connect both rooms later on using portals. Create a ProBuilder Cube and stretch it out to 4x4x6 again. Press “Invert Normals” to create a room.

Create 1 edge loop and extrude out the face 2 meters as shown below. This forms the geometry for a portal.

On the other side, extrude the face by 1 meter as shown below. This is the other side of the portal.

To top it all off, select the floor faces and use “Vertex Colors” to change their colours to your liking.

Let’s disable the original portals to save on performance and to prevent interference with our new portals since we won’t be using them anymore.

Finally, move the player to the new room. We’ll start them off in the red room.

Let’s begin!

Placing down the portals and hoping that it works

Let’s simply use the Portal prefab we made earlier in the tutorial. Once again, I highly recommend ProGrids to make sure the portal position is perfectly accurate.

First, let’s connect the 2 rooms together using 4x4 portals.

Don’t forget to link them up together. Each portal must reference the other side. For example, Portal A must target Portal B, and Portal B must target Portal A.

Next, let’s make the infinite mirror in the green room. Place the 4x4 portals as shown below, and link them up together in the inspector.

Finally, we’ll make the infinite corner in the red room. Resize the portals to 3x4, and place them as shown below. Link them up.

Click play!

The results… aren’t that great. However, the teleportation works fine, and you can get a gist of what we are trying to go for. You can even see yourself in one of the portals! Let’s discuss why the current solution doesn’t work, and how we can solve it.

Portal theory

Let’s take a look at what’s wrong with the portal highlighted in yellow.

The yellow portal is being rendered through the blue portal. This is how it looks like in 2D.

Here’s how the current portal system handles the Portal Camera. The yellow portal only cares about the player and does not care that it’s being seen through another portal. Because of that, the Portal Camera for the yellow portal is very far away, as shown below.

That will look correct if the player is looking at the yellow portal directly. However, they are not! They are viewing the yellow portal through the blue portal. Because of that, the yellow portal’s Portal Camera should be relative to the blue portal’s Portal Camera, instead of the player, as shown below.

How do we go about fixing this? We’ll need a brand new portal rendering system designed for recursive rendering.

We’ll need to render the inner portals first, then work our way out. In other words, to render a portal, we’ll have to render any inner portals visible to that portal before rendering it.

Take a look at this portal visibility graph. The player can see Portals A, B and D. Portal A can see Portals C and D. Portal B can also see Portal D.

As discussed above, to render this, we’ll have to render from the inside-out. So in this case, the order of rendering will be:

  1. Portal C relative to Portal A
  2. Portal D relative to Portal A
  3. Portal A relative to the Main Camera
  4. Portal D relative to the Main Camera
  5. Portal D relative to Portal B
  6. Portal B relative to the Main Camera
  7. The Main Camera renders as per usual.

Our current portal rendering system uses 1 RenderTexture per portal. Can you find the bug in the rendering order?

In step 4, Portal D renders relative to the Main Camera.

In step 5, Portal D renders relative to Portal B. This overwrites the render result from step 4.

In step 7, the Main Camera renders normally. It can see Portal D directly, but since Portal D’s RenderTexture is now relative to Portal B instead, it will look wrong.

The solution to that is to not assign 1 RenderTexture per portal, but per render instead. Refer to the diagram below for a basic example.

Here are the steps to render this frame:

  1. Render Portal C relative to Portal A, and store in RT 1. Assign RT 1 to Portal C’s material. Remember what texture it was before (null).
  2. Render Portal D relative to Portal A, and store in RT 2. Assign RT 2 to Portal D’s material. Remember what texture it was before (null).
  3. Render Portal A relative to the Main Camera, Portal C (RT 1) and Portal D (RT 2) visible. Store the result in RT 3. RT 1 and 2 can then be released. Assign the previous textures back to Portal C (null) and D (null).
  4. Render the Main Camera, Portal A (RT 3) visible.
  5. Release all RTs.

Now that you understand how this method works on a basic scale, we need to see how it solves our previous bug to fully understand why we have to use it.

For comparison’s sake, our old 1 RenderTexture per portal method:

  1. Portal D relative to the Main Camera
  2. Portal D relative to Portal B (the bug; overwrites step 1)
  3. Portal B relative to the Main Camera
  4. The Main Camera renders as per usual (but Portal D looks weird)

And now, our new method:

  1. Render Portal D relative to the Main Camera, and store in RT 1. Assign RT 1 to Portal D’s material. Remember what texture it was before (null).
  2. Render Portal D relative to Portal B, and store in RT 2. Assign RT 2 to Portal D’s material. Remember what texture it was before (RT 1).
  3. Render Portal B relative to the Main Camera, Portal D (RT 2) visible. Store the result in RT 3. RT 2 can then be released. Assign the previous texture back to Portal D (RT 1).
  4. Render the Main Camera, Portal D (RT 1) and Portal B (RT 3) visible.
  5. Release all RTs.

As you can see, with this method, Portal D has 2 instances of RenderTextures as it is required by 2 renderers (Portal B and the Main Camera). Portal B releases one of Portal D’s RenderTextures after it finishes rendering. It also sets Portal D back to RT 1, which is correct for the Main Camera.

In other words, this new method is non-destructive. It does not overwrite the previous rendering steps.

With this method, the bug is solved and Portal D can be seen both directly and through Portal B at the same time, without any weirdness.

Implementing recursive rendering

There are 3 parts to our system we need to implement.

  1. The visibility graph
  2. The RenderTexture pool manager
  3. The actual recursive rendering

Let’s go!

The visibility graph

This is a visibility graph. It defines what portals can be seen through other portals so that our recursive rendering logic knows what to render before portals. You can kind of think of it as a dependency graph, for portals.

How do we know which portals are visible to one another? There are methods of automating this, but the simplest way is to do it by hand.

Open up Portal.cs. We’ll simply use an array for this:

public Portal[] visiblePortals;

To visualize our visible portals and linked portals, let’s make it a gizmo:

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);
}
}

Go back to the scene. For each of the portals, assign the portals visible to them to the Visible Portals field, as shown below. For instance, if we have a set of 3 portals in a room all facing one another, each portal should have 2 visible portals, forming a blue triangle as seen below.

Remember that if Portal A can see Portal B, Portal B can also see Portal A. It is useful to keep that in mind when manually editing your visibility graph. If you are making a Portal Sanity Checker, it’ll be useful to check for that as well.

The RenderTexture pool manager

If we are going to create and release many render textures every frame when rendering, we’re going to need a pool manager.

Now, Unity already includes a built-in RenderTexture pool manager through RenderTexture.GetTemporary(), but from my experience, it tends to blow up in my face and allocate thousands of RenderTextures if there are any bugs. With our own pool manager, we can limit the number of RenderTextures created in case there is a runaway bug within our recursive rendering code.

We’ll define an Instance variable since this will be a singleton, a maxSize variable to define, well, the maximum size of the pool, and a list that holds the actual pool items.

using System.Collections.Generic;
using UnityEngine;
public class RenderTexturePool : MonoBehaviour
{
public static RenderTexturePool Instance;

public int maxSize = 100;
private List<PoolItem> pool = new List<PoolItem>();

Here is the PoolItem class. It can hold the actual RenderTexture reference, and a boolean defining if it is currently being used. Place this class inside the RenderTexturePool class.

public class PoolItem
{
public RenderTexture Texture;
public bool Used;
}

In Awake, we’ll make this component a singleton.

private void Awake()
{
Instance = this;
}

We’ll start off the pool logic by coding what happens when we try to get a new temporary RenderTexture. We check for all the pool items in the list. As soon as we come across one that is unused, we’ll simply mark it as used and return it.

// Gets a new temporary texture from the pool.
public PoolItem GetTexture()
{
// Check all pool items. Are any one of them unused?
// If so, take the first unused one we come across, mark it as used, and return it.

foreach (var poolItem in pool)
{
if (!poolItem.Used)
{
poolItem.Used = true;
return poolItem;
}
}

However, if none are found, we’ll have to expand the pool. First, we’ll check if the pool is at its maximum size. If it is, log it and throw an exception.

We’ll create a new texture using CreateTexture(), a function we’ll code later. Then, add the new pool item into the pool and then log the expansion. We can use the debug console to see the size of the pool later on. Finally, we’ll mark it as used and return it.

// Are none of them unused? Time to expand!if (pool.Count >= maxSize)
{
Debug.LogError("Pool is full!");
throw new OverflowException();
}
var newPoolItem = CreateTexture();
pool.Add(newPoolItem);
Debug.Log($"New RenderTexture created, pool is now {pool.Count} items big.");
newPoolItem.Used = true;
return newPoolItem;

The ReleaseTexture() code is simple. We simply mark it as unused.

// Releases the temporary texture back into the pool.
public void ReleaseTexture(PoolItem item)
{
// When releasing a texture, simply mark it as unused.
// No need to overwrite it or anything!

item.Used = false;
}

Next, releasing all textures just involves looping over the entire pool and releasing them one by one:

// Releases all temporary textures back to the pool.
public void ReleaseAllTextures()
{
foreach (var poolItem in pool)
{
ReleaseTexture(poolItem);
}
}

Now, for the code where we actually create the RenderTexture. This is very similar to the RenderTexture creation code we used in Portal.cs. After creating a fullscreen RenderTexture in both Unity and on the GPU, we’ll create a new PoolItem, containing a reference to the RenderTexture, which will be ready to be added into the pool.

// Actually create a new texture, taking up memory and all!
private PoolItem CreateTexture()
{
// As before, create a new RenderTexture with the full screen width and height.
// Use .Create() to create it on the GPU as well.

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

return new PoolItem
{
Texture = newTexture,
Used = false
};
}

Now, the code to actually destroy the RenderTexture from memory. First, we use .Release() to release it on the GPU. Next, we’ll use Unity’s Destroy() function to remove it from the engine.

// Actually destroy the texture. It'll be gone for good!
private void DestroyTexture(PoolItem item)
{
// First, release on the GPU...

item.Texture.Release();

// Then Destroy() to remove it from Unity completely.

Destroy(item.Texture);
}

We’ll be using this destroy code in OnDestroy() as shown below. When the pool is destroyed, either by changing scenes or the game quitting, we’ll loop through the entire pool and destroy them all.

private void OnDestroy()
{
// Do cleanup!
foreach (var poolItem in pool)
{
DestroyTexture(poolItem);
}
}

We now have a pool that can be used to generate temporary RenderTextures for usage within our portal rendering code. Furthermore, if there are any bugs in the portal rendering code, there is a maximum limit to prevent crashes.

Adding recursive rendering

Finally, with the pool and visibility graph in place, it is time to add recursive rendering.

Let’s start by creating a new C# script named PortalRenderer.cs.

using UnityEngine;

public class PortalRenderer : MonoBehaviour
{
public Camera portalCamera;
public int maxRecursions = 2;

public int debugTotalRenderCount;

private Camera mainCamera;
private Portal[] allPortals;

Let’s take a look at the variables we have here.

  • portalCamera: Holds a reference to the portal camera, which will be doing all the portal rendering. We will only have a single camera doing all the portal rendering from now on, instead of every portal having its own camera.
  • maxRecursions: The maximum depth of portals.
  • debugTotalRenderCount: A debug variable that will hold how many portal renders were performed in this frame. We’re making this a public variable, as an easy way to see the total render count from the inspector.
  • mainCamera: A cache to the main camera.
  • allPortals: An array that holds all the portals in the scene.

In our Start() function, we’ll cache the main camera and find all the portals in the scene.

private void Start()
{
mainCamera = Camera.main;
allPortals = FindObjectsOfType<Portal>();
}

OnPreRender() is called whenever a camera on the same GameObject is going to render. Here is where we can render the portals before the Main Camera. However, since we have not implemented the recursive rendering function yet, we’ll leave this empty for now.

As you can see, we’ll loop through every single portal in the scene and render all of them. This is inefficient but gets the job done, and shouldn’t result in any glitches if the recursive rendering order is implemented properly. We’ll look at optimizing performance in a future tutorial.

private void OnPreRender()
{
debugTotalRenderCount = 0;

foreach (var portal in allPortals)
{
// TODO: Render portal here
}
}

Finally, in OnPostRender(), called after the camera attached to the GameObject has been rendered, we’ll release all of the RenderTextures that have been used.

private void OnPostRender()
{
RenderTexturePool.Instance.ReleaseAllTextures();
}

So, OnPreRender() is called before a camera is rendered, and OnPostRender() is called after the camera is rendered. The camera in question is the Main Camera; we’ll render our portals before the Main Camera, and clean up after the Main Camera has rendered.

Because of this, we’ll attach our PortalRenderer component to the Main Camera object.

Don’t forget that we still need the Portal Camera object. Copy the Portal Camera from one of the Portals, and paste it as a child of the Main Camera. Disable the Portal Camera’s Camera component, since we’ll be manually rendering the camera from inside the recursive rendering logic.

Here’s a screenshot of the Portal Camera properties for reference.

Remember to drag the Portal Camera into the Portal Renderer’s field!

We can now delete the Portal Camera from the Portal prefab since we’ll be using the Portal Renderer’s camera instead of each Portal’s own camera. Double-click the Portal prefab, and delete the Portal Camera.

Let’s start work on the actual recursive rendering code. Open Portal.cs.

First, delete these two variables. Remember that each Portal no longer owns a Portal Camera and RenderTextures are pooled instead of per portal.

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

Add in this variable. This will hold the texture the portal will show when recursion is at maximum. By default, if we don’t assign anything into this variable, it will be a white texture (null). However, we can assign anything we want to make it less jarring.

public Texture viewthroughDefaultTexture;

For example, Quantum Tournament uses a texture with the same colour as the wall, to make it look like recursion goes on a lot more than it actually does. You can even use a texture that is a prebaked view of the world from the other side of the portal.

Let’s take a look at this piece of code in Start(). Here, we used to create a RenderTexture per portal, and assign it to our Portal Camera. Since this has now changed, we have to change this. Replace this…

// Create render textureviewthroughRenderTexture = 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;

With this.

// Get cloned material

viewthroughMaterial = viewthroughRenderer.material;
// Cache the main camera

mainCamera = Camera.main;

Next, remove the entire LateUpdate() function. We’ll be replacing all of that with a recursive function which will be called from PortalRenderer instead.

Create a function named RenderViewthroughRecursive as so:

public void RenderViewthroughRecursive(
Vector3 refPosition,
Quaternion refRotation,
out RenderTexturePool.PoolItem temporaryPoolItem,
out Texture originalTexture,
out int debugRenderCount,
Camera portalCamera,
int currentRecursion,
int maxRecursions)
{

Let’s go through the method signature.

  • refPosition: The position we will be rendering this portal relative to. For example, if this portal is directly visible and being rendered by the Main Camera, this will be the position of the Main Camera.
  • refPosition: The same thing, but rotation.
  • out temporaryPoolItem: If this portal (child) is being rendered by another portal (parent), the child portal will need to give the parent portal a reference to the RenderTexture pool item, so that the parent portal can release the RenderTexture after it’s done rendering.
  • out originalTexture: The child portal also needs to give the parent portal a reference to the original texture, so the parent portal can restore the child’s original texture after the parent portal finishes rendering. (If you are confused by this, please refer to the new non-destructive rendering method in the Portal Theory section above.)
  • out debugRenderCount: How many renders does this function call result in? This produces the debug count seen in PortalRenderer.
  • portalCamera: The portal camera from PortalRenderer, used to render the portals. Pretty straightforward.
  • currentRecursion: How deep are we in the recursion? 0 means directly visible to the Main Camera, a high number means visible through many, many portals.
  • maxRecursions: What is the recursion limit?

First, we’ll initialize the debug render count to 1. We’ll increase it later if we render more portals recursively.

debugRenderCount = 1;

Next, we’ll calculate the position and rotation of the Portal Camera. This is just like before, but instead of basing it off of the Main Camera, we’ll use the passed-in refPosition and refRotation instead. Refer to Part 1 for more details.

// 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);

The portal clipping code is completely the same. Refer to Part 1 for more details.

// 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;

We’ll create a new list to keep track of each of our visible (child) portal’s resources, such as their RenderTextures and original textures.

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

var visiblePortalResourcesList = new List<VisiblePortalResources>();

Here is the struct definition of visiblePortalResourcesList. Place it within the Portal class.

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

Back to RenderViewthroughRecursive...

If we are not at the recursion limit, we can render any portals visible to us!

We find out the list of portals visible to us using targetPortal.visiblePortals.

Next, we render the visible (child) portal. We pass in our Portal Camera’s position and rotation as the child’s refPosition and refRotation. We’ll accept the out variables. The portalCamera and maxRecursions remain the same, but we increment currentRecursion by 1.

Then, we’ll keep track of the child’s RenderTexture and original texture by creating a new VisiblePortalResources and adding it to the list.

Finally, we add the child’s render count to our render count.

// Recurse if not at limit

if (currentRecursion < maxRecursions)
{
foreach (var visiblePortal in targetPortal.visiblePortals)
{
visiblePortal.RenderViewthroughRecursive(
virtualPosition,
virtualRotation,
out var visiblePortalTemporaryPoolItem,
out var visiblePortalOriginalTexture,
out var visiblePortalRenderCount,
portalCamera,
currentRecursion + 1,
maxRecursions);

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

debugRenderCount += visiblePortalRenderCount;
}
}

If we are at the recursion limit, instead of telling the visible portals to render, we’ll tell each visible portal to show their default texture instead. We’ll still need to restore its original texture afterwards, so we still need to keep track of it through VisiblePortalResources.

We’ll implement ShowViewthroughDefaultTexture soon.

else
{
foreach (var visiblePortal in targetPortal.visiblePortals)
{
visiblePortal.ShowViewthroughDefaultTexture(out var visiblePortalOriginalTexture);

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

Now that our visible (child) portals have finished rendering, we’ll start the actual rendering process for this portal. First, we’ll get our a new temporary RenderTexture to render to.

Note that we are assigning it to out temporaryPoolItem, so our function will automatically return this variable to our parent portal.

// 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();

We have already calculated the Portal Camera’s position, rotation and clipping in a previous step. Now, we just need to set the Portal Camera’s render target to our new RenderTexture. Then, we’ll set the Portal Camera’s position, rotation and clipping matrix.

Finally, we render the camera, using a single function call: portalCamera.Render(). I love how simple and intuitive Unity’s API is here.

// Use portal camera

portalCamera.targetTexture = temporaryPoolItem.Texture;
portalCamera.transform.SetPositionAndRotation(virtualPosition, virtualRotation);
portalCamera.projectionMatrix = obliqueProjectionMatrix;

// Render portal camera to target texture

portalCamera.Render();

Why did you compute and set Portal Camera’s position, rotation and clipping matrix before recursion, instead of here? And aren’t you wasting performance setting them twice?

Yes, in this tutorial, there is no point setting Portal Camera’s properties before recursion. However, in the next tutorial, we will be using those properties before recursion to perform our own frustum culling, an important optimization step.

After we finished rendering, don’t forget to release our child Portals’ RenderTextures and restore their original textures.

// 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);
}
}

Finally, we’ll present our rendering result by assigning our new RenderTexture to our material. However, don’t forget to set the originalTexture variable first, so our parent portal may restore it.

// 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;

That is all for the recursive rendering method! That was quite a lot of code and theory to digest, but we’re almost done. (Remember, you can always find the full scripts at the end of the tutorial.)

Create a new function called ShowViewthroughDefaultTexture. If you recall, this was used in the recursive rendering method when the recursion limit is reached.

In this function, we return our originalTexture, then simply assign our pre-baked default texture to our material.

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

In the OnDestroy() function, we’ll need to remove a few lines since our Portal class is no longer managing its own RenderTexture. Remove the bolded lines as seen below.

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

viewthroughRenderTexture.Release();
// Destroy cloned material and render texture

Destroy(viewthroughMaterial);
Destroy(viewthroughRenderTexture);
}

You should have this in the end:

private void OnDestroy()
{
// Destroy cloned material

Destroy(viewthroughMaterial);
}

Finally, we’ll fill in PortalRenderer with the first call to the recursive rendering function. The refPosition and refRotation are based on the Main Camera’s position and rotation. We’ll ignore the RenderTexture and original texture since we’ll be releasing all RenderTextures anyway in OnPostRender(). The first currentRecursion starts at 0. The renderCount will be accumulated every frame, to calculate how many total portal renders are done per frame.

private void OnPreRender()
{
debugTotalRenderCount = 0;

foreach (var portal in allPortals)
{
portal.RenderViewthroughRecursive(
mainCamera.transform.position,
mainCamera.transform.rotation,
out _,
out _,
out var renderCount,
portalCamera,
0,
maxRecursions);

debugTotalRenderCount += renderCount;

}
}

Final result

It works! If you got it to work, then congratulations! This is a huge milestone.

Remember that all of these is just 2 small rooms. Play around with your own rooms and portal placements any way you like to make even more interesting levels. If you made something you’re really proud of, feel free to tag me on Twitter @limdingwen. I’d love to see your creations.

Closing

In this tutorial, you have learnt how to upgrade our portal rendering system to one that allows for recursive rendering, with multiple pairs of portals. To achieve this, you have also implemented a portal visibility graph and a RenderTexture pool.

In the next tutorial, we will discuss how we can optimize our recursive rendering strategy since our current one is very inefficient. This simple scene already costs 43 full renders per frame!

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

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 Renderer viewthroughRenderer;
private Material viewthroughMaterial;

private Camera mainCamera;

private Vector4 vectorPlane;

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

public Portal[] visiblePortals;

public Texture viewthroughDefaultTexture;

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

viewthroughMaterial = viewthroughRenderer.material;

// Cache the main camera

mainCamera = Camera.main;

// 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());
}

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,
LayerMask layerMask,
int maxRecursions,
out RaycastHit hitInfo)
{
return RaycastRecursiveInternal(position,
direction,
layerMask,
maxRecursions,
out hitInfo,
0,
null);
}

private static bool RaycastRecursiveInternal(
Vector3 position,
Vector3 direction,
LayerMask layerMask,
int maxRecursions,
out RaycastHit hitInfo,
int currentRecursion,
GameObject ignoreObject)
{
// 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
}

// Shoot raycast

var raycastHitSomething = Physics.Raycast(
position,
direction,
out var hit,
Mathf.Infinity,
layerMask); // Clamp to max array length

// 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),
layerMask,
maxRecursions,
out hitInfo,
currentRecursion + 1,
portal.targetPortal.gameObject);
}

// 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)
{
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>();

// Recurse if not at limit

if (currentRecursion < maxRecursions)
{
foreach (var visiblePortal in targetPortal.visiblePortals)
{
visiblePortal.RenderViewthroughRecursive(
virtualPosition,
virtualRotation,
out var visiblePortalTemporaryPoolItem,
out var visiblePortalOriginalTexture,
out var visiblePortalRenderCount,
portalCamera,
currentRecursion + 1,
maxRecursions);

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;

// 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;
}
}

PortalRenderer.cs

using UnityEngine;

public class PortalRenderer : MonoBehaviour
{
public Camera portalCamera;
public int maxRecursions = 2;

public int debugTotalRenderCount;

private Camera mainCamera;
private Portal[] allPortals;

private void Start()
{
mainCamera = Camera.main;
allPortals = FindObjectsOfType<Portal>();
}

private void OnPreRender()
{
debugTotalRenderCount = 0;

foreach (var portal in allPortals)
{
portal.RenderViewthroughRecursive(
mainCamera.transform.position,
mainCamera.transform.rotation,
out _,
out _,
out var renderCount,
portalCamera,
0,
maxRecursions);

debugTotalRenderCount += renderCount;
}
}

private void OnPostRender()
{
RenderTexturePool.Instance.ReleaseAllTextures();
}
}

RenderTexturePool.cs

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

public class RenderTexturePool : MonoBehaviour
{
public static RenderTexturePool Instance;

public int maxSize = 100;

private List<PoolItem> pool = new List<PoolItem>();

private void Awake()
{
Instance = this;
}

// Gets a new temporary texture from the pool.
public PoolItem GetTexture()
{
// Check all pool items. Are any one of them unused?
// If so, take the first unused one we come across, mark it as used, and return it.

foreach (var poolItem in pool)
{
if (!poolItem.Used)
{
poolItem.Used = true;
return poolItem;
}
}

// Are none of them unused? Time to expand!

if (pool.Count >= maxSize)
{
Debug.LogError("Pool is full!");
throw new OverflowException();
}

var newPoolItem = CreateTexture();
pool.Add(newPoolItem);
Debug.Log($"New RenderTexture created, pool is now {pool.Count} items big.");
newPoolItem.Used = true;
return newPoolItem;
}

// Releases the temporary texture back into the pool.
public void ReleaseTexture(PoolItem item)
{
// When releasing a texture, simply mark it as unused.
// No need to overwrite it or anything!

item.Used = false;
}

// Releases all temporary textures back to the pool.
public void ReleaseAllTextures()
{
foreach (var poolItem in pool)
{
ReleaseTexture(poolItem);
}
}

// Actually create a new texture, taking up memory and all!
private PoolItem CreateTexture()
{
// As before, create a new RenderTexture with the full screen width and height.
// Use .Create() to create it on the GPU as well.

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

return new PoolItem
{
Texture = newTexture,
Used = false
};
}

// Actually destroy the texture. It'll be gone for good!
private void DestroyTexture(PoolItem item)
{
// First, release on the GPU...

item.Texture.Release();

// Then Destroy() to remove it from Unity completely.

Destroy(item.Texture);
}

private void OnDestroy()
{
// Do cleanup!

foreach (var poolItem in pool)
{
DestroyTexture(poolItem);
}
}

public class PoolItem
{
public RenderTexture Texture;
public bool Used;
}
}

--

--