
Ever since I got into game development, one of the many things that peaked my interest is how the portal effect is achieved. Over the past week, I decided to give it a go, and came up with decent results I'm quite proud of.
Portal Effect
After doing some research, the basic portal effect is achieved by rendering a camera onto a plane by using a view texture. Using this technique, we can get everything we see from a camera, and print it out onto a texture. We then use that texture on the plane of the other portal, giving the illusion we are looking into another world.
Although there are 2 problems:
The other camera isn't moving relative to the player camera, so the portal view will be static.
The portal is seeing everything the camera sees, but we only want to render everything inside the portal.

believe it or not, the 2nd point is easier to resolve. To solve this issue, we simply cut out everything inside the portal view, and render that onto the view texture.

This can be achieved by using a shader. I'm not too familiar with shader code, but luckily there are plenty of tutorials online explaining how to do this.
Portal Shader
Shader "Custom/Portal"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
}
SubShader
{
Tags { "Queue" = "Transparent" "RenderType"="Opaque" }
Cull Off
Lighting Off
ZWrite On
ZTest Less
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
};
struct v2f
{
float4 vertex : SV_POSITION;
float4 screenPos : TEXCOORD0;
};
sampler2D _MainTex;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.screenPos = ComputeScreenPos(o.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
float2 screenSpaceUV = i.screenPos.xy / i.screenPos.w;
return tex2D(_MainTex, screenSpaceUV);
}
ENDCG
}
}
}
Now that we have the proper view, we still need to position and rotate the camera relative to the second portal, as the player camera is positioned and rotated relative to the first portal.

To get the relative position, I created a new Vector3, where each component is the dot product of the distance between the player and the portal, and the portals axis normalised.
eg, the x component of the new Vector would be:
Dot(playerPosition - portalPosition, portal.rightVector.normalised)
I made a function that does this for me:
public static Vector3 GetRelativePosition(Transform origin, Vector3 position)
{
Vector3 distance = position - origin.position;
Vector3 relativePosition;
relativePosition.x = Vector3.Dot(distance, origin.right.normalized);
relativePosition.y = Vector3.Dot(distance, origin.up.normalized);
relativePosition.z = Vector3.Dot(distance, origin.forward.normalized);
return relativePosition;
}
For the relative rotation, we need to multiply the inverse rotation of the other portal, by the current rotation of the player.
Inverse(portalRotation) * playerRotation;
Now in 3 lines of code (with a lot more behind the scenes), we get a cool portal effect...
Vector3 newPos = MathP.GetRelativePosition(linkedPortal.transform, playerCamera.transform.position);
Quaternion newRot = Quaternion.Inverse(linkedPortal.transform.rotation) * playerCamera.transform.rotation;
portalCamera.transform.SetLocalPositionAndRotation(newPos, newRot);

Teleportation Functionality
The next step is to implement the actual teleportation, where the player can walk through the portal to the other side.
The most obvious way to determine whether the player has passed through the portal is to get the dot product of the players offset from the portal and the forward vector of the portal. Because we want to be able to pass through both sides, we can cache the old side of the portal.
Vector3 offsetFromPortal = teleporterT.position - transform.position;
int portalSide = System.Math.Sign(Vector3.Dot(offsetFromPortal, transform.forward));
int oldPortalSide = System.Math.Sign(Vector3.Dot(teleporter.previousOffsetFromPortal, transform.forward));
if (portalSide != oldPortalSide)
{
//Calculate new position and rotation
Vector3 newPos = linkedPortal.transform.TransformPoint(MathP.GetRelativePosition(transform, teleporterT.position));
Quaternion newRot = (teleporterT.rotation * Quaternion.Inverse(transform.rotation)) * linkedPortal.transform.rotation;
teleporter.Teleport(transform, linkedPortal.transform, newPos, newRot);
}
else
{
teleporter.previousOffsetFromPortal = offsetFromPortal;
}
We then use similar math that we used before to get the relative position and rotation, and set the players transform.
Now the player can teleport!

As you can see above, right when the player is transported to the other world, there is a single frame where we can see the current world. This is due to the render plane intersecting with the clip plane of the camera, giving the ugly effect seen below.

I thought about this a lot, and found the best way to solve this is to move the render plane very slightly forward before we teleport, and just as the player teleports, move the render plane very slightly backwards, giving a seamless teleporting effect.
Below is the code is used to calculate the render plane movement stop stop the screen from flickering...
void ProtectScreenFromClipping()
{
//Get which side of the portal the player is on
bool portalSide = MathP.PortalSide(transform, playerCamera.transform);
//Offset the screen slightly so there is no plane clipping
Transform screenT = planeRenderer.transform;
Vector3 screenPos = screenT.localPosition;
screenPos.z = portalSide ? 0.05f : -0.05f;
screenT.localPosition = screenPos;
//Offset the other screen
Vector3 linkedPortalScreenPos = screenPos;
linkedPortalScreenPos.z = -screenPos.z;
linkedPortal.planeRenderer.transform.localPosition = linkedPortalScreenPos;
}
And now, with that implemented, the player can now teleport smoothly without any strange flickering! You can even move through the portal sideways and backwards smoothly!

Teleporting Objects
The portals are in a good state where the player can teleport smoothly without any issues. Although I wanted to go even further and have the ability to teleport other objects.
First I would need a way to slice the objects that would go through the portal. To accomplish this, I would need to write a slice shader, which I'm not very good at. Again, luckily there are heaps of tutorials online...
Shader "Custom/Slice"
{
Properties
{
_Color ("Color", Color) = (1,1,1,1)
_MainTex ("Albedo (RGB)", 2D) = "white" {}
_Glossiness ("Smoothness", Range(0,1)) = 0.5
_Metallic ("Metallic", Range(0,1)) = 0.0
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 200
CGPROGRAM
// Physically based Standard lighting model, and enable shadows on all light types
#pragma surface surf Standard fullforwardshadows
// Use shader model 3.0 target, to get nicer looking lighting
#pragma target 3.0
sampler2D _MainTex;
struct Input
{
float2 uv_MainTex;
float3 worldPos;
};
half _Glossiness;
half _Metallic;
fixed4 _Color;
float3 sliceCentre;
float3 sliceNormal;
// Add instancing support for this shader. You need to check 'Enable Instancing' on materials that use the shader.
// See https://docs.unity3d.com/Manual/GPUInstancing.html for more information about instancing.
// #pragma instancing_options assumeuniformscaling
UNITY_INSTANCING_BUFFER_START(Props)
// put more per-instance properties here
UNITY_INSTANCING_BUFFER_END(Props)
void surf (Input IN, inout SurfaceOutputStandard o)
{
float sliceSide = dot(sliceNormal, IN.worldPos - sliceCentre);
clip(-sliceSide);
// Albedo comes from a texture tinted by color
fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
o.Albedo = c.rgb;
// Metallic and smoothness come from slider variables
o.Metallic = _Metallic;
o.Smoothness = _Glossiness;
o.Alpha = c.a;
}
ENDCG
}
FallBack "Diffuse"
}
The shader works great in the core pipeline, though since I'm using the URP, surface shaders don't work anymore, as Unity has moved onto using the shader graph. No worries though, I just need to transfer everything from the shader to a shader graph, which was quite simple.
Below is the final slice shader in the Shader Graph...

Then by using the code below, I set the slice centre and slice normal depending on which way the object has entered portal.
public void SetupSlice(GameObject objectToSlice)
{
//Set the object to slice
slicedObject = objectToSlice;
materials = GetMaterials(slicedObject);
bool portalSide = MathP.PortalSide(transform.parent, slicedObject.transform);
Vector3 sliceNormal = portalSide ? -transform.up : transform.up;
//Apply the position and normal of he slice shader
for (int i = 0; i < materials.Count; i++)
{
materials[i].SetInt("_ClipPlane", 1);
materials[i].SetVector("_SliceCentre", transform.position + sliceNormal);
materials[i].SetVector("_SliceNormal", sliceNormal);
}
}

The slicing works just as intended, but there is still a slight problem. While the object is moving through the portal, we don't see it poking out the other side. To solve this, when the object enters the portal trigger, I spawn a copy object with the same mesh and scale, set its relative position and rotation, and reverse the slice normal. This is done in the code below.
void CreateCopyObject(Vector3 pos, Quaternion rot)
{
GameObject objectCopy = new GameObject("Object Copy");
objectCopy.transform.SetPositionAndRotation(pos, rot);
objectCopy.transform.localScale = transform.localScale;
objectCopy.AddComponent<MeshRenderer>();
MeshRenderer renderer = objectCopy.GetComponent<MeshRenderer>();
renderer.material = GetComponent<MeshRenderer>().material;
bool portalSide = MathP.PortalSide(linkedPortal.transform, objectCopy.transform);
Vector3 sliceNormal = portalSide ? linkedPortal.planeRenderer.transform.up : -linkedPortal.planeRenderer.transform.up;
renderer.material.SetVector("_SliceCentre", linkedPortal.planeRenderer.transform.position + sliceNormal);
renderer.material.SetVector("_SliceNormal", sliceNormal);
renderer.material.SetInt("_ClipPlane", 1);
objectCopy.AddComponent<MeshFilter>();
objectCopy.GetComponent<MeshFilter>().mesh = GetComponent<MeshFilter>().mesh;
teleporterCopy = objectCopy;
}
And doing so creates these results...

This is as far as I got. I know there is plenty more that could be done to improve them, but I am happy with the current results, and learnt quite a lot from making them.
Assets used:
Low Poly Trees: https://assetstore.unity.com/packages/3d/vegetation/trees/low-poly-tree-pack-57866
Low Poly Desert: https://assetstore.unity.com/packages/3d/environments/free-low-poly-desert-pack-106709
Skyboxes: https://assetstore.unity.com/packages/2d/textures-materials/sky/free-stylized-skybox-212257
Planets: https://assetstore.unity.com/packages/3d/environments/planets-of-the-solar-system-3d-90219
VHS Assets: https://assetstore.unity.com/packages/3d/environments/sci-fi/chromatic-chaos-vhs-asset-pack-248899