Skip to content

Instantly share code, notes, and snippets.

@limdingwen
Created April 17, 2020 00:50
Show Gist options
  • Save limdingwen/5d42b3f09a1fbf4fa422b9b60a76df2d to your computer and use it in GitHub Desktop.
Save limdingwen/5d42b3f09a1fbf4fa422b9b60a76df2d to your computer and use it in GitHub Desktop.
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
using UnityEngine.Profiling;
using UnityEngine.Serialization;
using LayerMask = UnityEngine.LayerMask;
public class Portal : MonoBehaviour
{
[FormerlySerializedAs("target")]
[SerializeField] private Portal targetPortal;
/// <summary>
/// A transform whose forward represents the normal of the visible plane of the portal.
/// </summary>
[FormerlySerializedAs("normal")]
[SerializeField] private Transform normalVisible;
/// <summary>
/// A transform whose forward represents the normal of the invisible plane of the portal.
/// </summary>
[SerializeField] private Transform normalInvisible;
[SerializeField] private Shader viewthroughShader;
/// <summary>
/// When rendering recursively, if the recurse limit is reached, use this default texture instead.
/// </summary>
[SerializeField] private Texture viewthroughDefaultTexture;
private MeshRenderer viewthroughRenderer;
private Material viewthroughMaterial;
[SerializeField] private Portal[] visiblePortals;
/// <summary>
/// Overrides the recursion rendering amount, for views going OUT of this portal.
/// For example, if the value is 6, any portal that sees through this portal will see a recursion level of 6.
/// Lower (or set to 0) for performance reasons, or increase for better infinite portal effects with limited performance hit.
/// Set to any number less than 0 (default -1) to disable the override.
///
/// This override only applies if the portal is being rendered by a portal that the player is directly with,
/// in the same occlusion volume.
/// </summary>
[SerializeField] private int maxRecursionsDirectOverride = -1;
/// <summary>
/// Same as above, but only if the portal is being rendered by a portal that was already recursively rendered.
/// Use this to optimize for cases where an infinite portal might need to be rendered many times when seen directly,
/// but not that many times when seen from another occlusion volume.
/// </summary>
[SerializeField] private int maxRecursionsIndirectOverride = -1;
[SerializeField] private bool teleportEnable = true;
private readonly HashSet<PortalableObject> teleportObjectsTouchingPortal = new HashSet<PortalableObject>();
private readonly HashSet<PortalableObject> teleportObjectsToRemove = new HashSet<PortalableObject>();
/// <summary>
/// How many meters between each OffMeshLink?
/// </summary>
[SerializeField] private float offMeshLinkResolution = 0.2f;
[SerializeField] private Transform offMeshLinkRef1;
[SerializeField] private Transform offMeshLinkRef2;
[SerializeField] private int offMeshLinkArea;
private readonly List<PortalOffMeshLink> offMeshLinks = new List<PortalOffMeshLink>();
private new Collider collider;
private WaitForFixedUpdate waitForFixedUpdate;
/// <summary>
/// A Vector4 math representation of the plane of the portal. Used for clipping purposes by other viewthrough portals.
/// </summary>
private Vector4 vectorPlane;
public Portal TargetPortal => targetPortal;
public Portal[] VisiblePortals => visiblePortals;
public bool ViewthroughVisible => viewthroughRenderer.isVisible;
public bool VisibleInCameraPlanes(Plane[] cameraPlanes) =>
GeometryUtility.TestPlanesAABB(cameraPlanes, collider.bounds);
public bool ShouldRender(Plane[] cameraPlanes) => ViewthroughVisible && VisibleInCameraPlanes(cameraPlanes);
public float OffMeshLinkResolution => offMeshLinkResolution;
private void Awake()
{
collider = GetComponent<Collider>();
viewthroughRenderer = GetComponent<MeshRenderer>();
// Generate material
viewthroughMaterial = new Material(viewthroughShader);
viewthroughRenderer.material = viewthroughMaterial;
// 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);
// Generate OffMeshLinks
// TODO: Offload to editor-time
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;
newTransform.tag = "Portal OffMeshLink";
offMeshLinks.Add(new PortalOffMeshLink()
{
RefTransform = newTransform
});
}
// Cache for coroutine
waitForFixedUpdate = new WaitForFixedUpdate();
}
private IEnumerator 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; // Use the default cost of the area assigned below (hopefully 0)
newLink.autoUpdatePositions = false;
newLink.activated = true;
newLink.area = offMeshLinkArea;
//offMeshLink.Link = newLink;
}
// Main loop
while (true)
{
// Wait for end of fixed update frame, that means, after OnTriggerEnter so we can check for any fresh objects
yield return waitForFixedUpdate;
CheckPortalCrossing();
}
// ReSharper disable once IteratorNeverReturns
}
private void OnTriggerEnter(Collider other)
{
if (!teleportEnable)
return;
var portalableObject = other.GetComponent<PortalableObject>();
if (!portalableObject)
return;
if (teleportObjectsTouchingPortal.Contains(portalableObject))
return;
teleportObjectsTouchingPortal.Add(portalableObject);
portalableObject.OnEnterPortal(this, targetPortal);
// Check portal crossing immediately
// in case the player crosses the portal in the same frame
// OMITTING THIS IS WHY one-frame behind-portal flashes happen!!
// CheckPortalCrossing();
// Using WaitForFixedUpdate instead
}
private void OnTriggerExit(Collider other)
{
if (!teleportEnable)
return;
var portalableObject = other.GetComponent<PortalableObject>();
if (!portalableObject)
return;
if (!teleportObjectsTouchingPortal.Contains(portalableObject))
return;
teleportObjectsTouchingPortal.Remove(portalableObject);
portalableObject.OnExitPortal(this, targetPortal);
}
private void OnDrawGizmos()
{
if (targetPortal)
{
Gizmos.color = Color.red;
Gizmos.DrawLine(transform.position, targetPortal.transform.position);
}
Gizmos.color = Color.blue;
foreach (var visiblePortal in visiblePortals)
{
Gizmos.DrawLine(transform.position, visiblePortal.transform.position);
}
}
private void OnDestroy()
{
Destroy(viewthroughMaterial);
}
private void CheckPortalCrossing()
{
if (!teleportEnable)
return;
// Clear removal queue
teleportObjectsToRemove.Clear();
// Check every touching object
foreach (var portalableObject in teleportObjectsTouchingPortal)
{
// Check if portalable object is behind the portal
// If so, we can assume they have crossed through the portal.
// Implying from this, you should not be able to touch a portal from behind.
// This can be changed later to allow portals to be touched from behind, but no support for now.
var pivot = portalableObject.Pivot ? portalableObject.Pivot : portalableObject.transform;
var directionToPivotFromTransform = pivot.position - transform.position;
directionToPivotFromTransform.Normalize();
var pivotToNormalDotProduct = Vector3.Dot(directionToPivotFromTransform, normalVisible.forward);
if (pivotToNormalDotProduct > 0) continue;
// Warp object
portalableObject.OnWillTeleport(this, targetPortal);
var newPosition = TransformPositionBetweenPortals(this, targetPortal, portalableObject.transform.position);
var newRotation = TransformRotationBetweenPortals(this, targetPortal, portalableObject.transform.rotation);
if (portalableObject.AutoTeleport)
portalableObject.transform.SetPositionAndRotation(newPosition, newRotation);
else
portalableObject.OnManualTeleport(this, targetPortal, newPosition, newRotation);
portalableObject.OnHasTeleported(this, targetPortal, newPosition, newRotation);
// Update physics transforms after warp to force update
// CharacterController requires this to teleport. (Now done using PortalableObject callbacks)
// Physics.SyncTransforms();
// Object is no longer touching this side of the portal
teleportObjectsToRemove.Add(portalableObject);
// Add to target portal so target portal will not trigger OnEnterPortal event next frame
// This is so that we can control the change better with OnChangePortal with per-frame accuracy
targetPortal.teleportObjectsTouchingPortal.Add(portalableObject);
portalableObject.OnChangePortal(targetPortal, this); // Teleported, they're on the other side now
// TODO: Check for OnExitPortal in case the player is teleported to a position that isn't touching the exit portal,
// TODO: thus not triggering OnExitTrigger (eg going too fast)
}
// Remove all objects queued up for removal
// Also causes this Portal to not trigger OnExitPortal event next frame
foreach (var portalableObject in teleportObjectsToRemove)
{
teleportObjectsTouchingPortal.Remove(portalableObject);
}
}
/// <summary>
/// For use in recursive rendering, set default viewthrough texture temporarily for rendering by caller.
/// </summary>
private void ShowViewthroughDefaultTexture(out Texture originalTexture)
{
originalTexture = viewthroughMaterial.mainTexture;
viewthroughMaterial.mainTexture = viewthroughDefaultTexture;
}
// TODO: Separate public function to start the recursion safely
/// <summary>
/// Renders the portal, with recursions.
/// The portal will follow the manually-defined portal visibility graph up to a certain number of recursions, depth-first.
/// It will then render the innermost portals first, followed by outer ones, all the way until the original one is rendered.
/// REMEMBER TO RELEASE temporaryRenderTexturePoolItem!!! Responsibility is on the CALLER so the caller can render the portal texture
/// before releasing it. If you no longer need any render textures (eg render done), call ReleaseAll in the pool.
/// </summary>
public void RenderViewthroughRecursive(
Vector3 refPosition,
Quaternion refRotation,
out PortalRenderTexturePoolManager.Item temporaryRenderTexturePoolItem,
out Texture originalTexture,
out int renderCount,
Camera portalCamera,
int currentRecursion,
int maxRecursions,
PortalOcclusionVolume currentOcclusionVolume,
LayerMask noCloneMask,
LayerMask renderCloneMask,
Portal portalNotRenderingClone)
{
renderCount = 1;
#region Calculate For Recursion
Profiler.BeginSample("Calculate Portals");
// Calculate virtual camera position and rotation
var virtualPosition = TransformPositionBetweenPortals(this, targetPortal, refPosition);
var virtualRotation = TransformRotationBetweenPortals(this, targetPortal, refRotation);
Profiler.EndSample();
// Setup portal camera for calculations
portalCamera.transform.SetPositionAndRotation(virtualPosition, virtualRotation);
// Convert target portal's plane to camera space (relative to target camera)
// Explanation: https://danielilett.com/2019-12-18-tut4-3-matrix-matching/
Profiler.BeginSample("Calculate Camera Space Portals");
var targetViewThroughPlaneCameraSpace =
Matrix4x4.Transpose(Matrix4x4.Inverse(portalCamera.worldToCameraMatrix))
* targetPortal.vectorPlane;
Profiler.EndSample();
// Set portal camera projection matrix to clip walls between target portal and target camera
// Inherits main camera near/far clip plane and FOV settings
Profiler.BeginSample("Calculate Oblique Matrix Portals");
var obliqueProjectionMatrix = GameManager.Instance.MainCamera.CalculateObliqueMatrix(targetViewThroughPlaneCameraSpace);
portalCamera.projectionMatrix = obliqueProjectionMatrix;
Profiler.EndSample();
// Generate camera planes for visibility testing
var cameraPlanes = GeometryUtility.CalculateFrustumPlanes(portalCamera);
// Store visible portal resources to release and reset (see function description for details)
var visiblePortalResourcesList = new List<VisiblePortalResources>();
#endregion
#region Recurse Portals
// Recurse if not at limit
var portalInCurrentVolume = false;
foreach (var portalInVolume in currentOcclusionVolume.Portals)
{
if (portalInVolume == this)
{
portalInCurrentVolume = true;
}
}
var localMaxRecursionsOverride =
portalInCurrentVolume ? targetPortal.maxRecursionsDirectOverride : targetPortal.maxRecursionsIndirectOverride;
if (currentRecursion <
(localMaxRecursionsOverride < 0
? maxRecursions
: localMaxRecursionsOverride))
{
foreach (var visiblePortal in targetPortal.visiblePortals)
{
if (!visiblePortal.ShouldRender(cameraPlanes))
continue;
visiblePortal.RenderViewthroughRecursive(
virtualPosition,
virtualRotation,
out var visiblePortalTemporaryRenderTexturePoolItem,
out var visiblePortalOriginalTexture,
out var visiblePortalRenderCount,
portalCamera,
currentRecursion + 1,
maxRecursions,
currentOcclusionVolume,
noCloneMask,
renderCloneMask,
portalNotRenderingClone);
visiblePortalResourcesList.Add(new VisiblePortalResources()
{
OriginalTexture = visiblePortalOriginalTexture,
PortalRenderTexturePoolItem = visiblePortalTemporaryRenderTexturePoolItem,
VisiblePortal = visiblePortal
});
renderCount += visiblePortalRenderCount;
}
}
else
{
foreach (var visiblePortal in targetPortal.visiblePortals)
{
if (!visiblePortal.ShouldRender(cameraPlanes))
continue;
visiblePortal.ShowViewthroughDefaultTexture(out var visiblePortalOriginalTexture);
visiblePortalResourcesList.Add(new VisiblePortalResources()
{
OriginalTexture = visiblePortalOriginalTexture,
VisiblePortal = visiblePortal
});
}
}
#endregion
#region Render Portal
Profiler.BeginSample("Render Portals");
// 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.
Profiler.BeginSample("Allocate Texture Portals");
temporaryRenderTexturePoolItem = PortalRenderTexturePoolManager.Instance.Allocate();
Profiler.EndSample();
// Use portal camera
Profiler.BeginSample("Setup Camera Portals");
portalCamera.targetTexture = temporaryRenderTexturePoolItem.Resource;
portalCamera.transform.SetPositionAndRotation(virtualPosition, virtualRotation);
portalCamera.projectionMatrix = obliqueProjectionMatrix;
portalCamera.cullingMask = (currentRecursion == 0 && this == portalNotRenderingClone
? noCloneMask
: renderCloneMask).value;
Profiler.EndSample();
// Render portal camera to target texture
Profiler.BeginSample("Actually Render Portals");
portalCamera.Render();
Profiler.EndSample();
Profiler.EndSample();
#endregion
#region Release and Restore
Profiler.BeginSample("Release and Restore Portals");
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.PortalRenderTexturePoolItem != null)
{
PortalRenderTexturePoolManager.Instance.Release(resources.PortalRenderTexturePoolItem);
}
}
Profiler.EndSample();
#endregion
#region Present Portal
Profiler.BeginSample("Present Portals");
// 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 = temporaryRenderTexturePoolItem.Resource;
Profiler.EndSample();
#endregion
}
public static Vector3 TransformPositionBetweenPortals(Portal sender, Portal target, Vector3 position)
{
return
target.normalInvisible.TransformPoint(
sender.normalVisible.InverseTransformPoint(position));
}
public static Vector3 TransformVectorBetweenPortals(Portal sender, Portal target, Vector3 direction)
{
return
target.normalInvisible.TransformVector(
sender.normalVisible.InverseTransformVector(direction));
}
public static Vector3 TransformDirectionBetweenPortals(Portal sender, Portal target, Vector3 direction)
{
return
target.normalInvisible.TransformDirection(
sender.normalVisible.InverseTransformDirection(direction));
}
public static Quaternion TransformRotationBetweenPortals(Portal sender, Portal target, Quaternion rotation)
{
return
target.normalInvisible.rotation *
Quaternion.Inverse(sender.normalVisible.rotation) *
rotation;
}
/// <summary>
/// Raycasts recursively through portals.
/// </summary>
/// <param name="position">Origin position</param>
/// <param name="direction">Direction of ray</param>
/// <param name="customPreRaycast">(Current recursion) => Any preparation before the current raycast</param>
/// <param name="layerMaskSelector">(Last source portal traversed, Current recursion) => LayerMask to use</param>
/// <param name="maxRecursions">Maximum recursions (if max recursions reached, treated as if no hit)</param>
/// <param name="hitInfo">Returned final raycast hit info</param>
/// <param name="finalDirection">Returned direction of the final raycast</param>
/// <returns>Was anything hit?</returns>
public static bool RaycastRecursive(Vector3 position,
Vector3 direction,
Action<int> customPreRaycast,
Func<Portal, int, LayerMask> layerMaskSelector,
int maxRecursions,
out RaycastHit hitInfo,
out Vector3 finalDirection)
{
return RaycastRecursiveInternal(position,
direction,
customPreRaycast,
layerMaskSelector,
maxRecursions,
out hitInfo,
out finalDirection,
0,
null,
null);
}
private static bool RaycastRecursiveInternal(Vector3 position,
Vector3 direction,
Action<int> customPreRaycast,
Func<Portal, int, LayerMask> layerMaskSelector,
int maxRecursions,
out RaycastHit hitInfo,
out Vector3 finalDirection,
int currentRecursion,
GameObject ignoreObject,
Portal lastTraversedPortal)
{
finalDirection = direction;
// 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 pre-raycast
customPreRaycast?.Invoke(currentRecursion);
// Shoot raycast
var raycastHitSomething = Physics.Raycast(
position,
direction,
out var hit,
Mathf.Infinity,
layerMaskSelector(lastTraversedPortal, currentRecursion)); // 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;
}
var portal = PortalManager.Instance.GetPortalFromGameObjectCached(hit.collider.gameObject);
if (portal)
{
// If the object hit is a portal, recurse, unless we are already at max recursions
if (currentRecursion >= maxRecursions)
{
hitInfo = new RaycastHit(); // Dummy
return false;
}
// Continue going down the rabbit hole...
//Debug.Log($"Shooting recursive, #{currentRecursion+1}");
return RaycastRecursiveInternal(
TransformPositionBetweenPortals(portal, portal.TargetPortal, hit.point),
TransformDirectionBetweenPortals(portal, portal.TargetPortal, direction),
customPreRaycast,
layerMaskSelector,
maxRecursions,
out hitInfo,
out finalDirection,
currentRecursion + 1,
portal.TargetPortal.gameObject,
portal);
}
else
{
hitInfo = hit;
return true;
}
}
private struct VisiblePortalResources
{
public Portal VisiblePortal;
public PortalRenderTexturePoolManager.Item PortalRenderTexturePoolItem;
public Texture OriginalTexture;
}
private struct PortalOffMeshLink
{
public Transform RefTransform;
//public OffMeshLink Link;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment