wip: Path to Omnissiah

This commit is contained in:
Alexandre Coirier
2026-05-18 23:11:16 +02:00
parent c4ad46990b
commit 0ca3cafe96
36 changed files with 4194 additions and 6130 deletions
+3
View File
@@ -51,10 +51,13 @@ Material:
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _ClipDistance: 0
- _ClipInvert: 0
- _GrainSize: 0.4
- _GrainStrength: 0.2
- _HighlightMax: 1
- _HighlightMin: 0.5
- _MaskID: 0
- _QueueControl: 0
- _QueueOffset: 0
- _ShadowMax: 0.2
@@ -20,8 +20,8 @@ Material:
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: M_default_mask_0
m_Shader: {fileID: -6465566751694194690, guid: adf06fb1c82bec94d8daea4d30c135e9, type: 3}
m_Name: M_default_mask_A
m_Shader: {fileID: -6465566751694194690, guid: 10840deb9abeee9478cbbb5915f60f5c, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
@@ -57,6 +57,7 @@ Material:
- _GrainStrength: 0.2
- _HighlightMax: 1
- _HighlightMin: 0.5
- _MaskID: 1
- _MaskValueOnDisabled: 0
- _QueueControl: 0
- _QueueOffset: 0
@@ -20,8 +20,8 @@ Material:
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: M_default_mask_1
m_Shader: {fileID: -6465566751694194690, guid: adf06fb1c82bec94d8daea4d30c135e9, type: 3}
m_Name: M_default_mask_B
m_Shader: {fileID: -6465566751694194690, guid: 10840deb9abeee9478cbbb5915f60f5c, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
@@ -57,6 +57,7 @@ Material:
- _GrainStrength: 0.2
- _HighlightMax: 1
- _HighlightMin: 0.5
- _MaskID: 2
- _MaskInvert: 1
- _MaskValueOnDisabled: 1
- _QueueControl: 0
@@ -21,7 +21,7 @@ Material:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: M_Theatre_stainedGlass
m_Shader: {fileID: -6465566751694194690, guid: adf06fb1c82bec94d8daea4d30c135e9, type: 3}
m_Shader: {fileID: -6465566751694194690, guid: 10840deb9abeee9478cbbb5915f60f5c, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
@@ -57,6 +57,7 @@ Material:
- _GrainStrength: 0.2
- _HighlightMax: 1
- _HighlightMin: 0.5
- _MaskID: 1
- _MaskValueOnDisabled: 0
- _QueueControl: 0
- _QueueOffset: 0
File diff suppressed because it is too large Load Diff
@@ -1,54 +0,0 @@
#if UNITY_EDITOR || DEVELOPMENT_BUILD
using UnityEngine;
using Unheimlich.Features.Portals;
namespace Unheimlich.Dev
{
public class PortalAudioDebugger : MonoBehaviour
{
private Portal _portal;
private void Awake()
{
_portal = GetComponent<Portal>();
}
private void OnEnable()
{
if (_portal != null)
{
_portal.RoomLoaded += OnRoomLoadedDebug;
_portal.RoomUnloaded += OnRoomUnloadedDebug;
}
}
private void OnDisable()
{
if (_portal != null)
{
_portal.RoomLoaded -= OnRoomLoadedDebug;
_portal.RoomUnloaded -= OnRoomUnloadedDebug;
}
}
private void OnRoomLoadedDebug()
{
Debug.Log($"<color=green>[PortalDebug] RoomLoaded event fired on {gameObject.name}</color>");
if (_portal.LoadedRoomInstance != null)
{
AudioSource[] sources = _portal.LoadedRoomInstance.GetComponentsInChildren<AudioSource>();
Debug.Log($"<color=green>[PortalDebug] Found {sources.Length} AudioSources in loaded room:</color>");
foreach (var source in sources)
Debug.Log($" - {source.name} | Playing: {source.isPlaying} | Clip: {(source.clip != null ? source.clip.name : "NULL")}");
}
}
private void OnRoomUnloadedDebug()
{
Debug.Log($"<color=red>[PortalDebug] RoomUnloaded event fired on {gameObject.name}</color>");
}
}
}
#endif
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 897784c22112f5f48af4313022397d8e
@@ -0,0 +1,30 @@
using UnityEngine;
[ExecuteInEditMode, DefaultExecutionOrder(1)]
public class Portal : MonoBehaviour
{
[SerializeField, Range(0, 2)] private int _id;
[SerializeField, Range(0, 1)] private float _oval;
public int ID => _id;
public float Oval => _oval;
private void OnDrawGizmos()
{
Gizmos.matrix = transform.localToWorldMatrix;
Gizmos.color = Color.cyan;
Gizmos.DrawWireCube(Vector3.zero, new Vector3(1, 1, 0));
}
void OnEnable()
{
if (PortalManager.Instance)
PortalManager.Instance.RegisterPortal(this);
}
void OnDisable()
{
if (PortalManager.Instance)
PortalManager.Instance.UnregisterPortal(this);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3b841c8d159a88e4a81dbf12ab26e346
@@ -0,0 +1,110 @@
using OctoCore;
using UnityEngine;
using UnityEngine.SocialPlatforms;
[ExecuteInEditMode, DefaultExecutionOrder(0)]
public class PortalManager : MonoBehaviour
{
public static PortalManager Instance { get; private set; }
[System.Serializable]
public class State
{
public bool PlayerInside;
public Portal[] Portals;
}
[SerializeField, OctoReadOnly] private State _state;
void OnEnable()
{
_state = new State();
_state.Portals = new Portal[3];
if (!Instance)
Instance = this;
ResetMasks();
}
void OnDisable()
{
if (Instance == this)
Instance = null;
ResetMasks();
}
void Update()
{
UpdateMasks();
}
public void SetInside(bool inside)
{
_state.PlayerInside = inside;
UpdateMasks();
}
public void RegisterPortal(Portal portal)
{
_state.Portals[portal.ID] = portal;
ManageMask(portal, true);
}
public void UnregisterPortal(Portal portal)
{
ManageMask(portal, false);
_state.Portals[portal.ID] = null;
}
public void ManageMask(Portal portal, bool active)
{
if (!portal)
return;
if (_state.Portals[portal.ID] != portal)
{
RegisterPortal(portal);
return;
}
Vector3 camPos = Camera.main ? Camera.main.transform.position : Vector3.zero;
Matrix4x4 m = new Matrix4x4();
m.SetRow(0, new Vector4(camPos.x, camPos.y, camPos.z, !active || _state.PlayerInside ? 0 : 1));
m.SetRow(1, new Vector4(portal.transform.position.x, portal.transform.position.y, portal.transform.position.z, portal.Oval));
m.SetRow(2, new Vector4(portal.transform.up.x, portal.transform.up.y, portal.transform.up.z, portal.transform.localScale.x));
m.SetRow(3, new Vector4(portal.transform.forward.x, portal.transform.forward.y, portal.transform.forward.z, portal.transform.localScale.y));
SetMask(portal.ID, m);
}
private void UpdateMasks()
{
for (int i = 0; i < _state.Portals.Length; i++)
ManageMask(_state.Portals[i], true);
}
private void ResetMasks()
{
for (int i = 0; i < _state.Portals.Length; i++)
{
Matrix4x4 m = new Matrix4x4();
m.SetRow(0, Vector4.zero);
m.SetRow(1, Vector4.zero);
m.SetRow(2, Vector4.zero);
m.SetRow(3, Vector4.zero);
SetMask(i, m);
}
}
private void SetMask(int id, Matrix4x4 m)
{
Shader.SetGlobalMatrix($"_Mask{id}", m);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 746134082760dc24e8ffdf666230032d
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: a7af0836297e37f40b61bdecbb261265
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,733 +0,0 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering.Universal;
using Unheimlich.Features.Portals;
using Unheimlich.Core.Utils;
namespace Unheimlich.Features.Portals
{
/// <summary>
/// Core component of the seamless portal system.
/// Creates a real-time view through to a linked portal and handles object teleportation.
///
/// How it works:
/// 1. Renders the view from the linked portal's perspective to a texture
/// 2. Displays that texture on this portal's screen mesh
/// 3. Tracks objects (PortalTravellers) that enter the portal
/// 4. Teleports them when they cross the portal plane
/// 5. Uses shader-based slicing to prevent visual artifacts during crossing
/// </summary>
[RequireComponent(typeof(BoxCollider))]
public class Portal : MonoBehaviour
{
/// <summary>Global list of all active portals in the scene</summary>
public static List<Portal> AllPortals = new List<Portal>();
[Header("Main Settings")]
[SerializeField] private string _portalId = "";
[SerializeField] private Portal _linkedPortal;
[SerializeField] private MeshRenderer _screen;
[SerializeField] private int _recursionLimit = 3;
[Header("Advanced Settings")]
[SerializeField] private float _nearClipOffset = 0.05f;
[SerializeField] private float _nearClipLimit = 0.2f;
[SerializeField] private float _transitionDistance = 0.5f;
[SerializeField] private float _loadRoomDistance = 30f;
[Header("Room Loading")]
[SerializeField] private GameObject _roomPrefabToLoad;
[SerializeField] private Transform _roomSpawnPoint;
[SerializeField] private string _linkedPortalPathInRoom = "Portal";
[SerializeField] private string _linkedPortalId = "";
private RenderTexture _viewTexture;
private Camera _portalCam;
private Camera _playerCam;
private List<PortalTraveller> _trackedTravellers;
private MeshFilter _screenMeshFilter;
private GameObject _loadedRoomInstance;
private string _roomId;
private PortalDoor _door;
private int _lastRenderedFrame = -1;
private bool _playerIsTeleporting = false;
public event Action RoomLoaded;
public event Action RoomUnloaded;
public Portal LinkedPortal => _linkedPortal;
public GameObject LoadedRoomInstance => _loadedRoomInstance;
public bool IsDoorOpen => _door == null || _door.IsOpen;
private void Awake()
{
_playerCam = Camera.main;
_portalCam = GetComponentInChildren<Camera>();
// Create portal camera if one doesn't exist
if (_portalCam == null)
{
GameObject camObj = new GameObject(PortalConstants.PORTAL_CAMERA_NAME);
camObj.transform.SetParent(transform);
camObj.transform.localPosition = Vector3.zero;
camObj.transform.localRotation = Quaternion.identity;
_portalCam = camObj.AddComponent<Camera>();
}
// Configure portal camera to match player camera settings
_portalCam.enabled = false;
_portalCam.depth = _playerCam.depth - 1;
_portalCam.clearFlags = _playerCam.clearFlags;
_portalCam.backgroundColor = _playerCam.backgroundColor;
_portalCam.farClipPlane = _playerCam.farClipPlane;
_portalCam.nearClipPlane = _playerCam.nearClipPlane;
_portalCam.fieldOfView = _playerCam.fieldOfView;
_portalCam.cullingMask = _playerCam.cullingMask;
_portalCam.allowHDR = _playerCam.allowHDR;
_portalCam.allowMSAA = _playerCam.allowMSAA;
_portalCam.useOcclusionCulling = _playerCam.useOcclusionCulling;
// Configure URP camera data for shadows and better quality
var urpCamData = _portalCam.GetUniversalAdditionalCameraData();
if (urpCamData != null)
{
// Copy main camera URP settings
var mainCamData = _playerCam.GetUniversalAdditionalCameraData();
if (mainCamData != null)
{
urpCamData.renderType = CameraRenderType.Base;
urpCamData.renderShadows = mainCamData.renderShadows;
urpCamData.requiresDepthOption = mainCamData.requiresDepthOption;
urpCamData.requiresColorOption = mainCamData.requiresColorOption;
urpCamData.requiresDepthTexture = mainCamData.requiresDepthTexture;
urpCamData.requiresColorTexture = mainCamData.requiresColorTexture;
urpCamData.renderPostProcessing = false;
urpCamData.antialiasing = mainCamData.antialiasing;
urpCamData.antialiasingQuality = mainCamData.antialiasingQuality;
urpCamData.volumeLayerMask = 0;
}
else
{
// Fallback if main camera doesn't have URP data
urpCamData.renderType = CameraRenderType.Base;
urpCamData.renderShadows = true;
urpCamData.requiresDepthTexture = true;
urpCamData.renderPostProcessing = false;
urpCamData.volumeLayerMask = 0;
}
}
_trackedTravellers = new List<PortalTraveller>();
_screenMeshFilter = _screen.GetComponent<MeshFilter>();
_screen.material.SetInt(PortalConstants.SHADER_PROPERTY_DISPLAY_MASK, 1);
_door = GetComponentInChildren<PortalDoor>();
if (_roomPrefabToLoad != null)
{
_roomId = $"{gameObject.name}_{_roomPrefabToLoad.name}";
}
}
private void OnEnable()
{
if (!AllPortals.Contains(this))
AllPortals.Add(this);
}
private void OnDisable()
{
if (AllPortals.Contains(this))
AllPortals.Remove(this);
}
private void Start()
{
if (PortalRoomManager.Instance != null && _roomPrefabToLoad != null)
{
PortalRoomManager.Instance.RegisterPortal(this);
}
}
private void OnDestroy()
{
if (PortalRoomManager.Instance != null && _roomPrefabToLoad != null)
{
PortalRoomManager.Instance.UnregisterPortal(this);
}
}
private void LateUpdate()
{
TryFindLinkedPortal();
HandleTravellers();
}
/// <summary>
/// Attempts to find and link to a portal in a dynamically loaded room.
/// Called each frame until a link is established.
/// </summary>
private void TryFindLinkedPortal()
{
if (_linkedPortal != null || PortalRoomManager.Instance == null || _roomPrefabToLoad == null)
return;
GameObject loadedRoom = PortalRoomManager.Instance.GetLoadedRoom(_roomId);
if (loadedRoom != null)
{
_loadedRoomInstance = loadedRoom;
FindLinkedPortalInRoom(loadedRoom);
}
}
/// <summary>
/// Searches for the linked portal within a loaded room.
/// First tries to find by portal ID, then falls back to path-based search.
/// </summary>
private void FindLinkedPortalInRoom(GameObject room)
{
Portal foundPortal = null;
// Try to find portal by ID first
if (!string.IsNullOrEmpty(_linkedPortalId))
{
Portal[] portalsInRoom = room.GetComponentsInChildren<Portal>();
foreach (Portal portal in portalsInRoom)
{
if (portal._portalId == _linkedPortalId)
{
foundPortal = portal;
break;
}
}
if (foundPortal == null)
{
Debug.LogWarning($"[{name}] Could not find portal with ID '{_linkedPortalId}' in room '{room.name}'. Falling back to path search.");
}
}
// Fallback to path-based search
if (foundPortal == null && !string.IsNullOrEmpty(_linkedPortalPathInRoom))
{
Transform portalTransform = room.transform.Find(_linkedPortalPathInRoom);
if (portalTransform != null)
{
foundPortal = portalTransform.GetComponent<Portal>();
}
}
// Establish bidirectional link
if (foundPortal != null)
{
_linkedPortal = foundPortal;
_linkedPortal.SetLinkedPortal(this);
if (_viewTexture != null && _linkedPortal._screen != null)
{
_linkedPortal._screen.material.SetTexture(PortalConstants.SHADER_PROPERTY_MAIN_TEXTURE, _viewTexture);
}
}
else
{
Debug.LogError($"[{name}] Could not find any portal to link in room '{room.name}'!");
}
}
/// <summary>
/// Tracks all travellers in the portal and teleports them when they cross the portal plane.
///
/// How crossing detection works:
/// 1. Calculate which side of the portal the traveller is on using dot product
/// 2. Compare with previous frame's side
/// 3. If side changed = traveller crossed the portal plane
/// 4. Transform traveller's position/rotation through portal transformation matrix
/// 5. Teleport to the linked portal
/// </summary>
private void HandleTravellers()
{
if (_linkedPortal == null)
{
return;
}
for (int i = 0; i < _trackedTravellers.Count; i++)
{
PortalTraveller traveller = _trackedTravellers[i];
Transform travellerT = traveller.transform;
// Calculate transformation matrix: from this portal's space to linked portal's space
var m = _linkedPortal.transform.localToWorldMatrix * transform.worldToLocalMatrix * travellerT.localToWorldMatrix;
// Determine which side of portal the traveller is on
Vector3 offsetFromPortal = travellerT.position - transform.position;
int portalSide = Math.Sign(Vector3.Dot(offsetFromPortal, transform.forward));
int portalSideOld = Math.Sign(Vector3.Dot(traveller.PreviousOffsetFromPortal, transform.forward));
// If side changed, traveller has crossed the portal
if (portalSide != portalSideOld)
{
if (traveller.CompareTag("Player"))
{
_playerIsTeleporting = true;
_linkedPortal._playerIsTeleporting = true;
}
// Hide both original and clone during teleport
traveller.SetGraphicsVisibility(false);
traveller.SetCloneVisibility(false);
// Teleport with transformed position and rotation
traveller.Teleport(transform, _linkedPortal.transform, m.GetColumn(3), m.rotation);
Physics.SyncTransforms();
// Show traveller at new location
traveller.SetGraphicsVisibility(true);
// Transfer tracking to the linked portal
_linkedPortal.OnTravellerEnterPortal(traveller);
_trackedTravellers.RemoveAt(i);
i--;
}
else
{
// Update clone position to match transformed position
traveller.GraphicsClone.transform.SetPositionAndRotation(m.GetColumn(3), m.rotation);
traveller.PreviousOffsetFromPortal = offsetFromPortal;
}
}
}
/// <summary>
/// Called before rendering. Updates shader slicing parameters for all tracked travellers.
/// </summary>
public void PrePortalRender()
{
if (_linkedPortal == null)
return;
foreach (var traveller in _trackedTravellers)
{
UpdateSliceParams(traveller);
}
}
/// <summary>
/// Main portal rendering function.
/// Creates a virtual camera at the linked portal's position and renders the view to a texture.
/// Supports recursive rendering (portal looking at portal looking at portal...).
/// </summary>
public void Render()
{
// Prevent rendering same portal multiple times per frame
if (_lastRenderedFrame == Time.frameCount)
return;
_lastRenderedFrame = Time.frameCount;
// Special case: player is teleporting, render without screen
if (_playerIsTeleporting)
{
_screen.enabled = false;
_portalCam.Render();
_screen.enabled = true;
_playerIsTeleporting = false;
return;
}
if (_linkedPortal == null)
{
if (_roomPrefabToLoad != null)
{
// Silently skip rendering while waiting for room to load
return;
}
}
if (_linkedPortal == null || _linkedPortal._screen == null)
{
if (_roomPrefabToLoad != null)
{
// Silently skip rendering while waiting for linked portal
return;
}
Debug.LogError($"[{name}] Cannot render: linked portal is null or has NULL _screen!");
return;
}
float distanceToCamera = Vector3.Distance(_playerCam.transform.position, transform.position);
Vector3 offsetFromPortal = _playerCam.transform.position - transform.position;
float dotToPortal = Vector3.Dot(offsetFromPortal.normalized, transform.forward);
// Don't render screen when player is crossing through
bool playerCrossingPortal = distanceToCamera < 0.5f && dotToPortal > 0.5f;
if (playerCrossingPortal)
{
_screen.enabled = false;
return;
}
// Don't render during transition to avoid flickering
if (distanceToCamera < _transitionDistance && dotToPortal > 0.5f)
{
return;
}
// Frustum culling: don't render if portal screen not visible
if (!PortalCameraUtility.VisibleFromCamera(_linkedPortal._screen, _playerCam))
{
return;
}
CreateViewTexture();
if (!_viewTexture.IsCreated())
{
_viewTexture.Create();
}
// Calculate recursive portal positions (for portal-in-portal effect)
var localToWorldMatrix = _playerCam.transform.localToWorldMatrix;
var renderPositions = new Vector3[_recursionLimit];
var renderRotations = new Quaternion[_recursionLimit];
int startIndex = 0;
_portalCam.projectionMatrix = _playerCam.projectionMatrix;
// Calculate transform matrices for each recursion level
for (int i = 0; i < _recursionLimit; i++)
{
if (i > 0)
{
// Stop recursion if screens don't overlap
if (!PortalCameraUtility.BoundsOverlap(_screenMeshFilter, _linkedPortal._screenMeshFilter, _portalCam))
break;
}
// Transform camera through portal pair
localToWorldMatrix = transform.localToWorldMatrix * _linkedPortal.transform.worldToLocalMatrix * localToWorldMatrix;
int renderOrderIndex = _recursionLimit - i - 1;
renderPositions[renderOrderIndex] = localToWorldMatrix.GetColumn(3);
renderRotations[renderOrderIndex] = localToWorldMatrix.rotation;
_portalCam.transform.SetPositionAndRotation(renderPositions[renderOrderIndex], renderRotations[renderOrderIndex]);
startIndex = renderOrderIndex;
}
_screen.enabled = false; // Hide current portal screen so camera can see through
// Render from each recursion depth
for (int i = startIndex; i < _recursionLimit; i++)
{
_portalCam.transform.SetPositionAndRotation(renderPositions[i], renderRotations[i]);
SetNearClipPlane();
HandleClipping();
_linkedPortal._screen.enabled = false;
_portalCam.Render();
_linkedPortal._screen.enabled = true;
}
_screen.enabled = true;
_linkedPortal._screen.material.mainTexture = _viewTexture;
}
/// <summary>
/// Handles shader-based clipping for travellers during rendering.
/// Determines which parts of original and clone should be visible based on portal positions.
/// Prevents visual artifacts when objects cross portal boundaries.
/// </summary>
private void HandleClipping()
{
const float hideDst = -1000;
const float showDst = 1000;
float screenThickness = _linkedPortal.ProtectScreenFromClipping(_portalCam.transform.position);
// Handle clipping for this portal's travellers
foreach (var traveller in _trackedTravellers)
{
// Original: hide if on same side as portal camera
if (SameSideOfPortal(traveller.transform.position, PortalCamPos))
traveller.SetSliceOffsetDst(hideDst, false);
else
traveller.SetSliceOffsetDst(showDst, false);
// Clone: adjust based on linked portal position
int cloneSideOfLinkedPortal = -SideOfPortal(traveller.transform.position);
bool camSameSideAsClone = _linkedPortal.SideOfPortal(PortalCamPos) == cloneSideOfLinkedPortal;
if (camSameSideAsClone)
traveller.SetSliceOffsetDst(screenThickness, true);
else
traveller.SetSliceOffsetDst(-screenThickness, true);
}
// Handle clipping for linked portal's travellers
foreach (var linkedTraveller in _linkedPortal._trackedTravellers)
{
var travellerPos = linkedTraveller.GraphicsObject.transform.position;
bool cloneOnSameSideAsCam = _linkedPortal.SideOfPortal(travellerPos) != SideOfPortal(PortalCamPos);
if (cloneOnSameSideAsCam)
linkedTraveller.SetSliceOffsetDst(hideDst, true);
else
linkedTraveller.SetSliceOffsetDst(showDst, true);
bool camSameSideAsTraveller = _linkedPortal.SameSideOfPortal(linkedTraveller.transform.position, PortalCamPos);
if (camSameSideAsTraveller)
linkedTraveller.SetSliceOffsetDst(screenThickness, false);
else
linkedTraveller.SetSliceOffsetDst(-screenThickness, false);
}
}
/// <summary>
/// Called after all portals have rendered. Resets clipping parameters.
/// </summary>
public void PostPortalRender()
{
foreach (var traveller in _trackedTravellers)
UpdateSliceParams(traveller);
ProtectScreenFromClipping(_portalCam.transform.position);
}
/// <summary>
/// Creates or recreates the render texture used to display the portal view.
/// Automatically adjusts to screen resolution changes.
/// </summary>
private void CreateViewTexture()
{
if (_viewTexture == null || _viewTexture.width != Screen.width || _viewTexture.height != Screen.height)
{
if (_viewTexture != null)
_viewTexture.Release();
_viewTexture = new RenderTexture(Screen.width, Screen.height, PortalConstants.RENDER_TEXTURE_DEPTH, PortalConstants.RENDER_TEXTURE_FORMAT);
_viewTexture.filterMode = FilterMode.Bilinear;
_viewTexture.antiAliasing = 1;
_viewTexture.Create();
_portalCam.targetTexture = _viewTexture;
_linkedPortal._screen.material.mainTexture = _viewTexture;
_linkedPortal._screen.material.SetInt(PortalConstants.SHADER_PROPERTY_DISPLAY_MASK, 1);
}
}
/// <summary>
/// Adjusts the portal screen's thickness to prevent camera near-clip plane from cutting through it.
/// The thickness is calculated based on the camera's near clip distance and FOV.
/// Returns the calculated thickness for use in clipping calculations.
/// </summary>
private float ProtectScreenFromClipping(Vector3 viewPoint)
{
float halfHeight = _playerCam.nearClipPlane * Mathf.Tan(_playerCam.fieldOfView * 0.5f * Mathf.Deg2Rad);
float halfWidth = halfHeight * _playerCam.aspect;
float dstToNearClipPlaneCorner = new Vector3(halfWidth, halfHeight, _playerCam.nearClipPlane).magnitude;
float screenThickness = dstToNearClipPlaneCorner;
Transform screenT = _screen.transform;
bool camFacingSameDirAsPortal = Vector3.Dot(transform.forward, transform.position - viewPoint) > 0;
screenT.localScale = new Vector3(screenT.localScale.x, screenT.localScale.y, screenThickness);
return screenThickness;
}
/// <summary>
/// Updates shader parameters for slicing a traveller's graphics at the portal plane.
/// Sets the slice position, normal, and offset for both original and clone materials.
/// This creates the effect of objects being "cut" by the portal surface.
/// </summary>
private void UpdateSliceParams(PortalTraveller traveller)
{
int side = SideOfPortal(traveller.transform.position);
Vector3 sliceNormal = transform.forward * -side;
Vector3 cloneSliceNormal = _linkedPortal.transform.forward * side;
Vector3 slicePos = transform.position;
Vector3 cloneSlicePos = _linkedPortal.transform.position;
float sliceOffsetDst = 0;
float cloneSliceOffsetDst = 0;
float screenThickness = _screen.transform.localScale.z;
// Adjust slicing based on player and traveller relative positions
bool playerSameSideAsTraveller = SameSideOfPortal(_playerCam.transform.position, traveller.transform.position);
if (!playerSameSideAsTraveller)
sliceOffsetDst = -screenThickness;
bool playerSameSideAsCloneAppearing = side != _linkedPortal.SideOfPortal(_playerCam.transform.position);
if (!playerSameSideAsCloneAppearing)
cloneSliceOffsetDst = -screenThickness;
// Apply slicing parameters to all materials
for (int i = 0; i < traveller.OriginalMaterials.Length; i++)
{
traveller.OriginalMaterials[i].SetVector(PortalConstants.SHADER_PROPERTY_SLICE_CENTER, slicePos);
traveller.OriginalMaterials[i].SetVector(PortalConstants.SHADER_PROPERTY_SLICE_NORMAL, sliceNormal);
traveller.OriginalMaterials[i].SetFloat(PortalConstants.SHADER_PROPERTY_SLICE_OFFSET, sliceOffsetDst);
traveller.CloneMaterials[i].SetVector(PortalConstants.SHADER_PROPERTY_SLICE_CENTER, cloneSlicePos);
traveller.CloneMaterials[i].SetVector(PortalConstants.SHADER_PROPERTY_SLICE_NORMAL, cloneSliceNormal);
traveller.CloneMaterials[i].SetFloat(PortalConstants.SHADER_PROPERTY_SLICE_OFFSET, cloneSliceOffsetDst);
}
}
/// <summary>
/// Sets up an oblique projection matrix to use the portal surface as the near clip plane.
/// This prevents objects between the camera and portal from being rendered.
/// Uses camera-space calculations to create a custom projection matrix.
/// </summary>
private void SetNearClipPlane()
{
Transform clipPlane = transform;
int dot = Math.Sign(Vector3.Dot(clipPlane.forward, transform.position - _portalCam.transform.position));
// Transform portal plane to camera space
Vector3 camSpacePos = _portalCam.worldToCameraMatrix.MultiplyPoint(clipPlane.position);
Vector3 camSpaceNormal = _portalCam.worldToCameraMatrix.MultiplyVector(clipPlane.forward) * dot;
float camSpaceDst = -Vector3.Dot(camSpacePos, camSpaceNormal) + _nearClipOffset;
// Only use oblique projection if far enough from portal
if (Mathf.Abs(camSpaceDst) > _nearClipLimit)
{
Vector4 clipPlaneCameraSpace = new Vector4(camSpaceNormal.x, camSpaceNormal.y, camSpaceNormal.z, camSpaceDst);
_portalCam.projectionMatrix = _playerCam.CalculateObliqueMatrix(clipPlaneCameraSpace);
}
else
_portalCam.projectionMatrix = _playerCam.projectionMatrix;
}
/// <summary>
/// Called when a traveller enters this portal's trigger zone.
/// Initializes tracking and creates the visual clone.
/// </summary>
private void OnTravellerEnterPortal(PortalTraveller traveller)
{
if (!_trackedTravellers.Contains(traveller))
{
traveller.EnterPortalThreshold();
traveller.PreviousOffsetFromPortal = traveller.transform.position - transform.position;
_trackedTravellers.Add(traveller);
}
}
/// <summary>
/// Unity physics callback when object enters portal's trigger collider.
/// </summary>
private void OnTriggerEnter(Collider other)
{
var traveller = other.GetComponent<PortalTraveller>();
if (traveller)
OnTravellerEnterPortal(traveller);
}
/// <summary>
/// Unity physics callback when object exits portal's trigger collider.
/// Cleans up the clone if traveller exits without teleporting.
/// </summary>
private void OnTriggerExit(Collider other)
{
var traveller = other.GetComponent<PortalTraveller>();
if (traveller && _trackedTravellers.Contains(traveller))
{
traveller.ExitPortalThreshold();
_trackedTravellers.Remove(traveller);
}
}
public void SetLinkedPortal(Portal portal)
{
_linkedPortal = portal;
}
public GameObject GetRoomPrefab() => _roomPrefabToLoad;
public string GetRoomId() => _roomId;
public string GetLinkedPortalPath() => _linkedPortalPathInRoom;
/// <summary>
/// Returns the world position where the room should be spawned.
/// Uses room spawn point if set, otherwise calculates based on portal position.
/// </summary>
public Vector3 GetRoomSpawnPosition()
{
if (_roomSpawnPoint != null)
return _roomSpawnPoint.position;
return transform.position + (transform.up + transform.right).normalized * _loadRoomDistance;
}
/// <summary>
/// Returns the rotation for spawning the room.
/// </summary>
public Quaternion GetRoomSpawnRotation()
{
if (_roomSpawnPoint != null)
return _roomSpawnPoint.rotation;
return transform.rotation;
}
/// <summary>
/// Called by PortalRoomManager when the associated room is loaded.
/// </summary>
public void OnRoomLoaded(GameObject room)
{
_loadedRoomInstance = room;
RoomLoaded?.Invoke();
}
/// <summary>
/// Called by PortalRoomManager when the associated room is unloaded.
/// Clears the linked portal reference.
/// </summary>
public void OnRoomUnloaded()
{
RoomUnloaded?.Invoke();
_loadedRoomInstance = null;
if (_roomPrefabToLoad != null)
_linkedPortal = null;
}
/// <summary>
/// Determines which side of the portal a position is on.
/// Returns +1 if in front (same direction as portal forward), -1 if behind.
/// Uses dot product to compare position relative to portal plane.
/// </summary>
public int SideOfPortal(Vector3 pos)
{
return Math.Sign(Vector3.Dot(pos - transform.position, transform.forward));
}
/// <summary>
/// Checks if two positions are on the same side of the portal.
/// </summary>
public bool SameSideOfPortal(Vector3 posA, Vector3 posB)
{
return SideOfPortal(posA) == SideOfPortal(posB);
}
public Portal GetLinkedPortal() => _linkedPortal;
public string GetPortalId() => _portalId;
public string GetLinkedPortalId() => _linkedPortalId;
public PortalDoor GetDoor() => _door;
public float GetRoomLoadDistance() => _loadRoomDistance;
private Vector3 PortalCamPos => _portalCam.transform.position;
/// <summary>
/// Unity editor callback to maintain bidirectional portal links in the inspector.
/// </summary>
private void OnValidate()
{
if (_linkedPortal != null)
_linkedPortal._linkedPortal = this;
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 5bee016a7303e2c458585f5cc66817e2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 200
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,307 +0,0 @@
using UnityEngine;
using System.Collections.Generic;
using Unheimlich.Features.Portals;
using Unheimlich.Core.Utils;
using Unheimlich.Dev;
namespace Unheimlich.Features.Portals
{
/// <summary>
/// Creates virtual audio proxies for sounds coming from the other side of a portal.
/// When a portal links to a room, sounds from that room are projected through the portal
/// so the player hears them spatially from the portal's location. This creates seamless
/// audio transitions when walking through portals.
/// </summary>
public class PortalAudioProxy : MonoBehaviour
{
[Header("Settings")]
[SerializeField] private float _maxDistance = 20f;
[SerializeField] private float _fadeStartDistance = 15f;
[SerializeField] private float _transitionDistance = 2f;
[SerializeField] private bool _useLowPassFilter = true;
[SerializeField] private float _lowPassCutoff = 5000f;
[SerializeField] private float _timeSyncThreshold = 0.1f;
[SerializeField] private float _minSpread = 60f;
[SerializeField] private float _maxSpread = 180f;
// [SerializeField] private float _volumeRolloffPower = 2f;
[Header("Debug")]
[SerializeField] private List<AudioSource> _remoteSources = new List<AudioSource>();
private Portal _portal;
private Dictionary<AudioSource, AudioSource> _proxyMapping = new Dictionary<AudioSource, AudioSource>();
private Dictionary<AudioSource, AudioLowPassFilter> _filterMapping = new Dictionary<AudioSource, AudioLowPassFilter>();
private Camera _playerCamera;
private GameObject _lastLoadedRoomInstance;
private bool _needsRefresh;
private void Awake()
{
_portal = GetComponent<Portal>();
_playerCamera = Camera.main;
}
private void OnEnable()
{
if (_portal != null)
{
_portal.RoomLoaded += OnRoomLoaded;
_portal.RoomUnloaded += OnRoomUnloaded;
}
}
private void OnDisable()
{
if (_portal != null)
{
_portal.RoomLoaded -= OnRoomLoaded;
_portal.RoomUnloaded -= OnRoomUnloaded;
}
}
private void OnRoomLoaded()
{
_needsRefresh = true;
}
private void OnRoomUnloaded()
{
_needsRefresh = true;
}
/// <summary>
/// Main update loop: refreshes audio sources when the room changes,
/// then updates all proxy positions and volumes each frame.
/// </summary>
private void Update()
{
if (_portal == null || _portal.LinkedPortal == null)
return;
GameObject currentLoadedRoom = _portal.LoadedRoomInstance;
if (currentLoadedRoom != _lastLoadedRoomInstance)
{
_lastLoadedRoomInstance = currentLoadedRoom;
_needsRefresh = true;
}
if (_needsRefresh)
{
RefreshRemoteSources();
_needsRefresh = false;
}
UpdateProxies();
}
/// <summary>
/// Scans the loaded room for all AudioSource components and caches them.
/// Called whenever a new room is loaded through the portal.
/// </summary>
private void RefreshRemoteSources()
{
GameObject linkedRoom = _portal.LoadedRoomInstance;
if (linkedRoom == null)
{
_remoteSources.Clear();
return;
}
AudioSource[] sources = linkedRoom.GetComponentsInChildren<AudioSource>();
_remoteSources.Clear();
_remoteSources.AddRange(sources);
}
/// <summary>
/// Updates all audio proxies each frame:
/// - Syncs playback state with remote sources
/// - Positions proxies at the portal location
/// - Adjusts volume based on distance and player orientation
/// - Applies low-pass filtering for sounds through the portal
/// This creates the illusion that sounds are coming from the portal opening.
/// </summary>
private void UpdateProxies()
{
Camera playerCam = _playerCamera;
if (playerCam == null)
return;
Portal linkedPortal = _portal.LinkedPortal;
if (linkedPortal == null)
return;
Vector3 playerPos = playerCam.transform.position;
Vector3 portalAPos = transform.position;
float distPlayerToPortalA = Vector3.Distance(playerPos, portalAPos);
Vector3 offsetFromPortalA = playerPos - portalAPos;
float dotToPortalA = Vector3.Dot(offsetFromPortalA.normalized, transform.forward);
bool playerFacingPortal = dotToPortalA > 0;
float transitionBlend = 0f;
if (playerFacingPortal && distPlayerToPortalA < _transitionDistance)
{
transitionBlend = 1f - (distPlayerToPortalA / _transitionDistance);
}
List<AudioSource> toRemove = new List<AudioSource>();
foreach (var remoteSource in _remoteSources)
{
if (remoteSource == null)
{
toRemove.Add(remoteSource);
continue;
}
if (!remoteSource.isPlaying)
{
if (_proxyMapping.ContainsKey(remoteSource))
{
AudioSource proxy = _proxyMapping[remoteSource];
if (proxy != null && proxy.isPlaying)
proxy.Stop();
}
continue;
}
AudioSource localProxy = GetOrCreateProxy(remoteSource);
if (localProxy.clip != remoteSource.clip)
{
localProxy.clip = remoteSource.clip;
localProxy.loop = remoteSource.loop;
localProxy.time = remoteSource.time;
localProxy.Play();
}
else if (!localProxy.isPlaying)
{
localProxy.time = remoteSource.time;
localProxy.Play();
}
else
{
float timeDifference = Mathf.Abs(localProxy.time - remoteSource.time);
if (timeDifference > _timeSyncThreshold)
localProxy.time = remoteSource.time;
}
localProxy.pitch = remoteSource.pitch;
if (_useLowPassFilter && _filterMapping.ContainsKey(remoteSource))
{
AudioLowPassFilter filter = _filterMapping[remoteSource];
if (filter != null)
filter.enabled = !_portal.IsDoorOpen;
}
Vector3 virtualPlayerPos = linkedPortal.transform.TransformPoint(transform.InverseTransformPoint(playerPos));
Vector3 proxyWorldPos = transform.TransformPoint(linkedPortal.transform.InverseTransformPoint(remoteSource.transform.position));
localProxy.transform.position = proxyWorldPos;
float baseVolume = remoteSource.volume;
AudioSourceDemo demo = remoteSource.GetComponent<AudioSourceDemo>();
if (demo != null)
baseVolume = demo.OriginalVolume;
localProxy.volume = baseVolume * (1f - transitionBlend);
if (demo != null)
{
float sourceFade = demo.GetDistanceFadeMultiplier(virtualPlayerPos);
localProxy.volume *= sourceFade;
}
float distanceToPortal = Vector3.Distance(playerPos, portalAPos);
if (distanceToPortal > _fadeStartDistance)
{
float fadeRange = _maxDistance - _fadeStartDistance;
float fadeAmount = (distanceToPortal - _fadeStartDistance) / fadeRange;
fadeAmount = Mathf.Clamp01(fadeAmount);
localProxy.volume *= (1f - fadeAmount);
}
if (localProxy.volume <= PortalConstants.MIN_VOLUME_THRESHOLD && localProxy.isPlaying)
{
localProxy.Stop();
continue;
}
localProxy.spread = Mathf.Lerp(_minSpread, _maxSpread, transitionBlend);
}
foreach (var source in toRemove)
{
if (_proxyMapping.ContainsKey(source))
{
AudioSource proxy = _proxyMapping[source];
if (proxy != null)
Destroy(proxy.gameObject);
_proxyMapping.Remove(source);
}
if (_filterMapping.ContainsKey(source))
_filterMapping.Remove(source);
_remoteSources.Remove(source);
}
}
private AudioSource GetOrCreateProxy(AudioSource remoteSource)
{
if (_proxyMapping.ContainsKey(remoteSource) && _proxyMapping[remoteSource] != null)
return _proxyMapping[remoteSource];
GameObject proxyObj = new GameObject($"AudioProxy_{remoteSource.name}");
proxyObj.transform.SetParent(transform);
proxyObj.transform.localPosition = Vector3.zero;
AudioSource proxy = proxyObj.AddComponent<AudioSource>();
proxy.spatialBlend = 1f;
proxy.spatialize = true;
proxy.spatializePostEffects = true;
proxy.reverbZoneMix = 1f;
proxy.spread = 60f;
proxy.rolloffMode = remoteSource.rolloffMode;
proxy.minDistance = remoteSource.minDistance;
proxy.maxDistance = remoteSource.maxDistance;
proxy.playOnAwake = false;
if (_useLowPassFilter)
{
AudioLowPassFilter filter = proxyObj.AddComponent<AudioLowPassFilter>();
filter.cutoffFrequency = _lowPassCutoff;
filter.enabled = false;
_filterMapping[remoteSource] = filter;
}
_proxyMapping[remoteSource] = proxy;
return proxy;
}
private void OnDestroy()
{
foreach (var kvp in _proxyMapping)
{
if (kvp.Value != null)
Destroy(kvp.Value.gameObject);
}
_proxyMapping.Clear();
_filterMapping.Clear();
}
private void OnDrawGizmosSelected()
{
Gizmos.color = Color.cyan;
Gizmos.DrawWireSphere(transform.position, _maxDistance);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 1ef7477556cb1fa45ab6cf4a9422c405
@@ -1,45 +0,0 @@
using UnityEngine;
using Unheimlich.World.Environment;
namespace Unheimlich.Features.Portals
{
/// <summary>
/// A door that is synchronized between linked portals.
/// When opened/closed, it automatically syncs the state with the door on the other side.
/// </summary>
public class PortalDoor : DoorInteractable
{
private Portal _parentPortal;
protected override void Start()
{
base.Start();
_parentPortal = GetComponentInParent<Portal>();
}
/// <summary>
/// Handles interaction with the door.
/// Toggles both this door and the linked portal's door simultaneously.
/// </summary>
public override void Interact()
{
SyncToggle();
}
/// <summary>
/// Toggles this door and synchronizes the state with the linked portal's door.
/// Ensures both doors open/close together for a consistent experience.
/// </summary>
private void SyncToggle()
{
ToggleDoor();
if (_parentPortal != null && _parentPortal.GetLinkedPortal() != null)
{
PortalDoor linkedDoor = _parentPortal.GetLinkedPortal().GetDoor();
if (linkedDoor != null)
linkedDoor.ToggleDoor();
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: f96b3c121a38b8e4784628da571e6c5b
@@ -1,67 +0,0 @@
using UnityEngine;
using UnityEngine.Rendering;
namespace Unheimlich.Features.Portals
{
/// <summary>
/// Main orchestrator for the portal rendering system.
/// Attach this to the main camera to enable seamless portal rendering.
/// Manages the render pipeline for all active portals in the scene.
///
/// URP-compatible version using RenderPipelineManager callbacks.
/// </summary>
public class PortalMainCamera : MonoBehaviour
{
private Camera _mainCamera;
private void Awake()
{
_mainCamera = GetComponent<Camera>();
}
private void OnEnable()
{
RenderPipelineManager.beginCameraRendering += OnBeginCameraRendering;
}
private void OnDisable()
{
RenderPipelineManager.beginCameraRendering -= OnBeginCameraRendering;
}
/// <summary>
/// Called before each camera renders in URP pipeline.
/// This is where we inject the portal rendering logic before the main camera renders.
/// </summary>
private void OnBeginCameraRendering(ScriptableRenderContext context, Camera camera)
{
// Only render portals for the main camera, not for portal cameras themselves
if (camera != _mainCamera)
return;
// Phase 1: Prepare all portals for rendering (update shader clipping parameters)
for (int i = 0; i < Portal.AllPortals.Count; i++)
{
var p = Portal.AllPortals[i];
if (p != null && p.isActiveAndEnabled)
p.PrePortalRender();
}
// Phase 2: Render all portals (create the view through each portal)
for (int i = 0; i < Portal.AllPortals.Count; i++)
{
var p = Portal.AllPortals[i];
if (p != null && p.isActiveAndEnabled)
p.Render();
}
// Phase 3: Clean up after portal rendering
for (int i = 0; i < Portal.AllPortals.Count; i++)
{
var p = Portal.AllPortals[i];
if (p != null && p.isActiveAndEnabled)
p.PostPortalRender();
}
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 98eb193612a097646bb0d333bb4aae51
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 300
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,288 +0,0 @@
using System.Collections.Generic;
using UnityEngine;
using Unheimlich.Core;
using Unheimlich.Features.Portals;
namespace Unheimlich.Features.Portals
{
/// <summary>
/// Manages dynamic loading and unloading of rooms connected by portals.
/// Monitors player distance to portals and loads/unloads room instances accordingly.
/// Supports room pooling (keeping inactive rooms in memory) and distance-based activation.
/// </summary>
public class PortalRoomManager : Singleton<PortalRoomManager>
{
[Header("Settings")]
[SerializeField] private float _loadDistance = 15f;
[Tooltip("Distance at which a loaded room gets deactivated (must be > load distance to avoid flip-flop)")]
[SerializeField] private float _deactivateDistance = 50f;
[Tooltip("Distance at which a deactivated room gets reactivated (should be < deactivate distance)")]
[SerializeField] private float _reactivateDistance = 30f;
[SerializeField] private bool _keepRoomsInMemory = true;
[Header("Distance Calculation")]
[Tooltip("If true, calculates distance from player to actual room position (portal + loadRoomDistance)")]
[SerializeField] private bool _useRealRoomDistance = true;
private Transform _player;
private Dictionary<string, LoadedRoom> _loadedRooms = new Dictionary<string, LoadedRoom>();
private List<Portal> _registeredPortals = new List<Portal>();
private GameObject _currentPlayerRoom = null;
/// <summary>
/// Represents a room instance that has been loaded through a portal.
/// Caches room bounds and colliders for efficient distance/containment calculations.
/// </summary>
private class LoadedRoom
{
public GameObject RoomInstance;
public string RoomId;
public Vector3 LoadPosition;
public Portal SourcePortal;
public Bounds CachedBounds;
public bool BoundsCalculated = false;
public Collider[] CachedColliders;
}
private void Start()
{
_player = GameObject.FindGameObjectWithTag("Player")?.transform;
if (_player == null)
Debug.LogWarning("Player not found! Make sure your player has the 'Player' tag.");
}
private void Update()
{
if (_player == null)
return;
CheckPortalsForLoading();
CheckRoomsForUnloading();
}
public void RegisterPortal(Portal portal)
{
if (!_registeredPortals.Contains(portal))
_registeredPortals.Add(portal);
}
public void UnregisterPortal(Portal portal)
{
if (_registeredPortals.Contains(portal))
_registeredPortals.Remove(portal);
}
public GameObject GetLoadedRoom(string roomId)
{
if (_loadedRooms.TryGetValue(roomId, out LoadedRoom loadedRoom))
return loadedRoom.RoomInstance;
return null;
}
/// <summary>
/// Checks all registered portals and loads rooms for portals within range.
/// Optionally accounts for the portal's configured room spawn distance.
/// </summary>
private void CheckPortalsForLoading()
{
foreach (Portal portal in _registeredPortals)
{
float distanceToPortal = Vector3.Distance(_player.position, portal.transform.position);
float effectiveLoadDistance = _loadDistance;
if (_useRealRoomDistance)
effectiveLoadDistance += portal.GetRoomLoadDistance();
if (distanceToPortal <= effectiveLoadDistance)
LoadRoom(portal);
}
}
/// <summary>
/// Checks all loaded rooms and determines if they should be deactivated, reactivated, or unloaded
/// based on distance thresholds. Prevents unloading the room the player is currently inside.
/// </summary>
private void CheckRoomsForUnloading()
{
List<string> roomsToUnload = new List<string>();
UpdateCurrentPlayerRoom();
foreach (var kvp in _loadedRooms)
{
LoadedRoom loadedRoom = kvp.Value;
if (loadedRoom.RoomInstance == null)
{
roomsToUnload.Add(kvp.Key);
continue;
}
if (loadedRoom.RoomInstance == _currentPlayerRoom)
continue;
float distanceToPortal = GetDistanceToRoom(loadedRoom);
float effectiveDeactivateDistance = _deactivateDistance;
float effectiveReactivateDistance = _reactivateDistance;
if (_useRealRoomDistance)
{
float roomOffset = loadedRoom.SourcePortal.GetRoomLoadDistance();
effectiveDeactivateDistance += roomOffset;
effectiveReactivateDistance += roomOffset;
}
if (distanceToPortal > effectiveDeactivateDistance)
{
if (loadedRoom.RoomInstance.activeSelf)
roomsToUnload.Add(kvp.Key);
}
else if (distanceToPortal <= effectiveReactivateDistance)
{
if (!loadedRoom.RoomInstance.activeSelf)
loadedRoom.RoomInstance.SetActive(true);
}
}
foreach (string roomId in roomsToUnload)
{
UnloadRoom(roomId);
}
}
/// <summary>
/// Calculates the distance from the player to a loaded room.
/// Lazily calculates and caches room bounds on first call for performance.
/// </summary>
private float GetDistanceToRoom(LoadedRoom loadedRoom)
{
if (!loadedRoom.BoundsCalculated)
{
loadedRoom.CachedBounds = CalculateRoomBounds(loadedRoom.RoomInstance);
loadedRoom.BoundsCalculated = true;
}
return Vector3.Distance(_player.position, loadedRoom.CachedBounds.center);
}
/// <summary>
/// Calculates the combined bounding box of all renderers in a room.
/// Used to determine room center for distance calculations.
/// </summary>
private Bounds CalculateRoomBounds(GameObject room)
{
Renderer[] renderers = room.GetComponentsInChildren<Renderer>();
if (renderers.Length == 0)
return new Bounds(room.transform.position, Vector3.one);
Bounds bounds = renderers[0].bounds;
for (int i = 1; i < renderers.Length; i++)
bounds.Encapsulate(renderers[i].bounds);
return bounds;
}
/// <summary>
/// Determines which room (if any) currently contains the player.
/// Uses cached collider bounds to detect room occupancy.
/// </summary>
private void UpdateCurrentPlayerRoom()
{
_currentPlayerRoom = null;
foreach (var kvp in _loadedRooms)
{
LoadedRoom loadedRoom = kvp.Value;
if (loadedRoom.RoomInstance == null)
continue;
Collider[] roomColliders = loadedRoom.CachedColliders;
if (roomColliders == null) continue;
foreach (Collider col in roomColliders)
{
if (col != null && col.bounds.Contains(_player.position))
{
_currentPlayerRoom = loadedRoom.RoomInstance;
return;
}
}
}
}
/// <summary>
/// Instantiates a room from a portal's prefab at the configured spawn point.
/// If the room is already loaded, reactivates it instead of creating a new instance.
/// Notifies the portal when its room is successfully loaded.
/// </summary>
private void LoadRoom(Portal portal)
{
GameObject roomPrefab = portal.GetRoomPrefab();
if (roomPrefab == null)
{
Debug.LogWarning($"[LOAD] Portal {portal.name} has no prefab!");
return;
}
string roomId = portal.GetRoomId();
if (_loadedRooms.TryGetValue(roomId, out LoadedRoom existingRoom))
{
if (existingRoom.RoomInstance != null)
existingRoom.RoomInstance.SetActive(true);
return;
}
Vector3 spawnPosition = portal.GetRoomSpawnPosition();
Quaternion spawnRotation = portal.GetRoomSpawnRotation();
GameObject roomInstance = Instantiate(roomPrefab, spawnPosition, spawnRotation);
roomInstance.name = $"{roomPrefab.name}_Loaded";
foreach (var renderer in roomInstance.GetComponentsInChildren<MeshRenderer>())
renderer.gameObject.SendMessage("OnBecameVisible", SendMessageOptions.DontRequireReceiver);
LoadedRoom loadedRoom = new LoadedRoom
{
RoomInstance = roomInstance,
RoomId = roomId,
LoadPosition = portal.transform.position,
SourcePortal = portal,
CachedColliders = roomInstance.GetComponentsInChildren<Collider>()
};
_loadedRooms.Add(roomId, loadedRoom);
portal.OnRoomLoaded(roomInstance);
}
/// <summary>
/// Deactivates or destroys a loaded room based on the _keepRoomsInMemory setting.
/// If keeping in memory, the room is deactivated but not destroyed (faster to reactivate).
/// If not keeping in memory, the room is fully destroyed and removed from tracking.
/// Notifies the source portal when its room is unloaded.
/// </summary>
private void UnloadRoom(string roomId)
{
if (_loadedRooms.TryGetValue(roomId, out LoadedRoom loadedRoom))
{
if (loadedRoom.RoomInstance != null)
{
if (loadedRoom.SourcePortal != null)
loadedRoom.SourcePortal.OnRoomUnloaded();
if (_keepRoomsInMemory)
loadedRoom.RoomInstance.SetActive(false);
else
{
Destroy(loadedRoom.RoomInstance);
_loadedRooms.Remove(roomId);
}
}
}
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 25abbf68f203db94381b5c22eb1573bc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 100
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,148 +0,0 @@
using System.Collections.Generic;
using UnityEngine;
using Unheimlich.Core.Utils;
namespace Unheimlich.Features.Portals
{
/// <summary>
/// Component that allows an object to travel through portals.
/// Handles teleportation, visual cloning when crossing, and shader-based slicing.
/// Attach this to any object that should be able to pass through portals (player, NPCs, etc.)
/// </summary>
[RequireComponent(typeof(CharacterController))]
public class PortalTraveller : MonoBehaviour
{
/// <summary>The visual representation of this traveller (mesh renderers)</summary>
public GameObject GraphicsObject;
/// <summary>Clone created when traveller enters portal threshold</summary>
public GameObject GraphicsClone { get; set; }
/// <summary>Tracks position relative to portal from previous frame to detect crossing</summary>
public Vector3 PreviousOffsetFromPortal { get; set; }
/// <summary>Materials of the original graphics (used for shader slicing)</summary>
public Material[] OriginalMaterials { get; set; }
/// <summary>Materials of the clone (used for shader slicing)</summary>
public Material[] CloneMaterials { get; set; }
private CharacterController _characterController;
private void Awake()
{
_characterController = GetComponent<CharacterController>();
}
/// <summary>
/// Teleports this traveller through a portal.
/// Temporarily disables CharacterController to prevent physics conflicts during teleport.
/// </summary>
/// <param name="fromPortal">The portal being exited</param>
/// <param name="toPortal">The destination portal</param>
/// <param name="pos">New world position</param>
/// <param name="rot">New world rotation</param>
public virtual void Teleport(Transform fromPortal, Transform toPortal, Vector3 pos, Quaternion rot)
{
if (_characterController != null)
_characterController.enabled = false;
transform.position = pos;
transform.rotation = rot;
if (_characterController != null)
{
_characterController.enabled = true;
Physics.SyncTransforms();
}
}
/// <summary>
/// Called when the traveller enters the portal's trigger zone.
/// Creates a visual clone that will appear on the other side of the portal.
/// </summary>
public virtual void EnterPortalThreshold()
{
if (GraphicsClone == null)
{
GraphicsClone = Instantiate(GraphicsObject);
GraphicsClone.transform.parent = GraphicsObject.transform.parent;
GraphicsClone.transform.localScale = GraphicsObject.transform.localScale;
OriginalMaterials = GetMaterials(GraphicsObject);
CloneMaterials = GetMaterials(GraphicsClone);
}
else
GraphicsClone.SetActive(true);
}
/// <summary>
/// Called when the traveller exits the portal's trigger zone without teleporting.
/// Hides the clone and resets shader slicing parameters.
/// </summary>
public virtual void ExitPortalThreshold()
{
GraphicsClone.SetActive(false);
for (int i = 0; i < OriginalMaterials.Length; i++)
OriginalMaterials[i].SetVector(PortalConstants.SHADER_PROPERTY_SLICE_NORMAL, Vector3.zero);
}
/// <summary>
/// Sets the slice offset distance for shader-based clipping.
/// Used to hide/show parts of the traveller based on portal plane position.
/// </summary>
/// <param name="dst">Distance offset for slicing plane</param>
/// <param name="clone">If true, applies to clone materials; otherwise to original</param>
public void SetSliceOffsetDst(float dst, bool clone)
{
for (int i = 0; i < OriginalMaterials.Length; i++)
{
if (clone)
CloneMaterials[i].SetFloat(PortalConstants.SHADER_PROPERTY_SLICE_OFFSET, dst);
else
OriginalMaterials[i].SetFloat(PortalConstants.SHADER_PROPERTY_SLICE_OFFSET, dst);
}
}
/// <summary>
/// Collects all materials from MeshRenderers in the graphics object hierarchy.
/// These materials will be modified with shader slicing parameters.
/// </summary>
private Material[] GetMaterials(GameObject g)
{
var renderers = g.GetComponentsInChildren<MeshRenderer>();
var matList = new List<Material>();
foreach (var renderer in renderers)
{
foreach (var mat in renderer.materials)
matList.Add(mat);
}
return matList.ToArray();
}
/// <summary>
/// Shows or hides the original graphics by enabling/disabling renderers.
/// </summary>
public void SetGraphicsVisibility(bool visible)
{
var renderers = GraphicsObject.GetComponentsInChildren<Renderer>();
foreach (var renderer in renderers)
renderer.enabled = visible;
}
/// <summary>
/// Shows or hides the clone graphics by enabling/disabling renderers.
/// </summary>
public void SetCloneVisibility(bool visible)
{
if (GraphicsClone != null)
{
var renderers = GraphicsClone.GetComponentsInChildren<Renderer>();
foreach (var renderer in renderers)
renderer.enabled = visible;
}
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: e825afa0efeb69949bf3ca8b3f99d7eb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 400
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+116 -22
View File
@@ -1,14 +1,38 @@
using OctoCore;
using OctoCore.Components;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using Unity.VisualScripting;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.InputSystem;
using UnityEngine.Rendering.Universal;
public class SpiralStaircase : MonoBehaviour
{
[System.Serializable]
public class DoorsPair
{
public Transform Left;
public Transform Right;
}
[System.Serializable]
public class State
{
public bool PlayerInside;
public int Turns;
public float TotalAngle;
[Range(0, 1)] public float Progression;
}
[System.Serializable]
public class References
{
public UniversalRendererData RendererData;
public GameObject Stairs;
public DoorsPair[] Doors;
public GameObject[] Inside;
public GameObject[] Outside;
}
@@ -21,28 +45,43 @@ public class SpiralStaircase : MonoBehaviour
public float EndAngle = 720;
}
[System.Serializable]
public class Events
{
public UnityEvent<bool> OnInside;
public UnityEvent OnTopReached;
public UnityEvent OnBottomReached;
}
[SerializeField, OctoReadOnly] private State _state;
[SerializeField] private References _references;
[SerializeField] private Settings _settings;
[SerializeField] private Events _events;
private bool _playerInside;
[SerializeField] private int _turns;
[SerializeField] private float _totalAngle;
[SerializeField] private float _progression;
private List<IEnumerator> _doorsCoroutine;
void Awake()
{
_doorsCoroutine = new List<IEnumerator>();
for (int i = 0; i < _references.Doors.Length; i++)
_doorsCoroutine.Add(null);
}
void Start()
{
ManageInside();
_events.OnBottomReached?.Invoke();
}
void OnDisable()
{
_playerInside = false;
_state.PlayerInside = false;
ManageInside();
}
void Update()
{
if (_playerInside)
if (_state.PlayerInside)
Count();
}
@@ -50,9 +89,9 @@ public class SpiralStaircase : MonoBehaviour
{
if (OctoPlayer.Instance && OctoPlayer.Instance.gameObject == col.gameObject)
{
if (!_playerInside)
if (!_state.PlayerInside)
{
_playerInside = true;
_state.PlayerInside = true;
ManageInside();
}
}
@@ -62,49 +101,104 @@ public class SpiralStaircase : MonoBehaviour
{
if (OctoPlayer.Instance && OctoPlayer.Instance.gameObject == col.gameObject)
{
if (_playerInside)
if (_state.PlayerInside)
{
_playerInside = false;
_state.PlayerInside = false;
ManageInside();
}
}
}
public void OpendDoors(bool open) { for (int i = 0; i < _references.Doors.Length; i++) OpendDoor(i, open); }
public void OpendDoor(int id) { OpendDoor(id, true); }
public void CloseDoor(int id) { OpendDoor(id, false); }
public void SetDoorOpened(int id) { OpendDoor(id, true, false); }
public void SetDoorClosed(int id) { OpendDoor(id, false, false); }
private void OpendDoor(int id, bool open, bool animate = true)
{
if (_doorsCoroutine[id] != null) StopCoroutine(_doorsCoroutine[id]);
StartCoroutine(_doorsCoroutine[id] = OpenDoorCoroutine(id, open, animate));
}
IEnumerator OpenDoorCoroutine(int id, bool open, bool animate)
{
const float duration = .6f;
DoorsPair door = _references.Doors[id];
Quaternion originRotL = door.Left.transform.localRotation;
Quaternion originRotR = door.Right.transform.localRotation;
Quaternion targetRotL = Quaternion.Euler(0, open ? 0 : 13.1f, 0);
Quaternion targetRotR = Quaternion.Euler(0, open ? 0 :-13.1f, 0);
if (animate)
{
for (float t = 0; t < duration; t += Time.deltaTime)
{
float lerp = .5f - Mathf.Cos(t / duration * Mathf.PI) * .5f;
door.Left.transform.localRotation = Quaternion.Lerp(originRotL, targetRotL, lerp);
door.Right.transform.localRotation = Quaternion.Lerp(originRotR, targetRotR, lerp);
yield return new WaitForEndOfFrame();
}
}
door.Left.transform.localRotation = targetRotL;
door.Right.transform.localRotation = targetRotR;
_doorsCoroutine[id] = null;
}
private void Count()
{
if (!OctoPlayer.Instance) return;
Vector2[] steps = new Vector2[4] { Vector2.up, Vector2.left, Vector2.down, Vector2.right };
Vector2 nextStep = steps[_turns % steps.Length];
Vector2 nextStep = steps[_state.Turns % steps.Length];
Vector3 relativePos3D = transform.InverseTransformPoint(OctoPlayer.Instance.transform.position);
Vector2 relativePos2D = new Vector2(relativePos3D.x, relativePos3D.z);
float quarterAngle = Vector2.Angle(nextStep, relativePos2D) * Mathf.Sign(Vector3.Cross(nextStep, relativePos2D).z);
_totalAngle = Mathf.Max(0, _turns * 90 + quarterAngle);
_state.TotalAngle = Mathf.Max(0, _state.Turns * 90 + quarterAngle);
if (quarterAngle > 90 && quarterAngle < 100)
{
_turns++;
_state.Turns++;
}
else if (quarterAngle < 0 && _turns > 0)
else if (quarterAngle < 0 && _state.Turns > 0)
{
_turns--;
_state.Turns--;
}
_progression = Mathf.Clamp01(Mathf.InverseLerp(_settings.StartAngle, _settings.EndAngle, _totalAngle));
float progression = Mathf.Clamp01(Mathf.InverseLerp(_settings.StartAngle, _settings.EndAngle, _state.TotalAngle));
_references.Stairs.transform.localPosition = new Vector3(0, _progression * _settings.Offset, 0);
if (progression >= .5f && _state.Progression < .5f)
{
_events.OnTopReached?.Invoke();
}
else if (progression < .5f && _state.Progression >= .5f)
{
_events.OnBottomReached?.Invoke();
}
_state.Progression = progression;
_references.Stairs.transform.localPosition = new Vector3(0, _state.Progression * _settings.Offset, 0);
}
private void ManageInside()
{
foreach (GameObject obj in _references.Inside) obj.SetActive(_playerInside);
foreach (GameObject obj in _references.Outside) obj.SetActive(!_playerInside);
foreach (GameObject obj in _references.Inside) obj.SetActive(_state.PlayerInside);
foreach (GameObject obj in _references.Outside) obj.SetActive(!_state.PlayerInside);
Shader.SetGlobalFloat("_MaskEnabled", _playerInside ? 0 : 1);
_events.OnInside?.Invoke(_state.PlayerInside);
/*Shader.SetGlobalFloat("_MaskEnabled", _state.PlayerInside ? 0 : 1);
foreach (var feature in _references.RendererData.rendererFeatures)
{
@@ -114,9 +208,9 @@ public class SpiralStaircase : MonoBehaviour
if (feature.name == "HiddenThroughMask")
{
feature.SetActive(!_playerInside);
feature.SetActive(!_state.PlayerInside);
break;
}
}
}*/
}
}
@@ -1,106 +0,0 @@
using UnityEngine;
using Unheimlich.Features.Portals;
namespace Unheimlich.World.CameraSystem
{
/// <summary>
/// Dynamically adjusts Unity's shadow rendering distance based on proximity to active portals.
/// When the player is near a portal with a loaded room, shadow distance is extended to ensure
/// shadows from the remote room are visible through the portal. This improves visual quality
/// while maintaining performance when far from portals.
/// </summary>
public class DynamicShadowDistance : MonoBehaviour
{
private const float PORTAL_CHECK_DISTANCE = 100f;
[Header("Shadow Distance Settings")]
[SerializeField] private float _baseShadowDistance = 50f;
[SerializeField] private float _extendedShadowDistance = 250f;
[SerializeField] private float _transitionSpeed = 2f;
[Header("Detection")]
[SerializeField] private float _checkInterval = 0.5f;
private float _targetShadowDistance;
private float _currentShadowDistance;
private float _nextCheckTime;
private Transform _playerTransform;
private void Awake()
{
_playerTransform = Camera.main != null ? Camera.main.transform : null;
}
private void Start()
{
_currentShadowDistance = _baseShadowDistance;
_targetShadowDistance = _baseShadowDistance;
QualitySettings.shadowDistance = _baseShadowDistance;
}
private void Update()
{
if (Time.time >= _nextCheckTime)
{
_nextCheckTime = Time.time + _checkInterval;
UpdateTargetShadowDistance();
}
if (!Mathf.Approximately(_currentShadowDistance, _targetShadowDistance))
{
_currentShadowDistance = Mathf.Lerp(_currentShadowDistance, _targetShadowDistance, Time.deltaTime * _transitionSpeed);
QualitySettings.shadowDistance = _currentShadowDistance;
}
}
/// <summary>
/// Checks if any active portal with a loaded room is within range.
/// If found, extends shadow distance to cover the remote room.
/// </summary>
private void UpdateTargetShadowDistance()
{
bool needsExtendedDistance = false;
foreach (var portal in Portal.AllPortals)
{
if (portal == null || !portal.isActiveAndEnabled)
continue;
if (portal.LoadedRoomInstance != null)
{
float distanceToPortal = Vector3.Distance(_playerTransform.position, portal.transform.position);
if (distanceToPortal < PORTAL_CHECK_DISTANCE)
{
needsExtendedDistance = true;
break;
}
}
}
_targetShadowDistance = needsExtendedDistance ? _extendedShadowDistance : _baseShadowDistance;
}
private void OnValidate()
{
if (_baseShadowDistance < 0)
_baseShadowDistance = 0;
if (_extendedShadowDistance < _baseShadowDistance)
_extendedShadowDistance = _baseShadowDistance;
}
#if UNITY_EDITOR
private void OnDrawGizmosSelected()
{
if (_playerTransform == null)
return;
Gizmos.color = new Color(1f, 1f, 0f, 0.2f);
Gizmos.DrawWireSphere(_playerTransform.position, QualitySettings.shadowDistance);
UnityEditor.Handles.Label(_playerTransform.position + Vector3.up * 2,
$"Shadow Distance: {QualitySettings.shadowDistance:F0}");
}
#endif
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: e8f216e6440b9d445ac547aa24daf2b7
+3 -3
View File
@@ -12,7 +12,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 6b3d386ba5cd94485973aee1479b272e, type: 3}
m_Name: HiddenThroughMask
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.RenderObjects
m_Active: 1
m_Active: 0
settings:
passTag: HiddenThroughMask
Event: 300
@@ -54,7 +54,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 6b3d386ba5cd94485973aee1479b272e, type: 3}
m_Name: VisibleThroughMask
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.RenderObjects
m_Active: 1
m_Active: 0
settings:
passTag: VisibleThroughMask
Event: 600
@@ -116,7 +116,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 6b3d386ba5cd94485973aee1479b272e, type: 3}
m_Name: Stencil Mask
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.RenderObjects
m_Active: 1
m_Active: 0
settings:
passTag: Stencil Mask
Event: 250
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,18 +0,0 @@
fileFormatVersion: 2
guid: adf06fb1c82bec94d8daea4d30c135e9
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 11500000, guid: 625f186215c104763be7675aa2d941aa, type: 3}
useAsTemplate: 0
exposeTemplateAsShader: 0
template:
name:
category:
description:
icon: {instanceID: 0}
thumbnail: {instanceID: 0}
@@ -67,61 +67,15 @@ float StableStripes(float x, float depth, float width, float smooth)
return smoothstep(w + s, w - s, abs(uv.x - .5) * 2);
}
void EnkiBillal_float
(
float4 Color,
float2 ScreenUV,
float3 PositionWS,
float3 PositionOS,
float3 NormalWS,
float3 NormalOS,
float3 ObjectScale,
float TriplanarBlend,
float StripesDensity,
float StripesWidth,
float StripesSmooth,
int Step,
out float3 Output
)
float WorldStripes()
{
float3 scaledPositionOS = PositionOS * ObjectScale;
float depth = length(PositionWS - _WorldSpaceCameraPos.xyz);
//Light mainLight = GetMainLight(0);
float3 mainLightDir = normalize(float3(1, -1, 1)); //normalize(float3(0, 13, 8) - PositionWS); //float3(0, -1, 0);//normalize(mainLight.direction);
float lightGradient = dot(normalize(NormalWS), normalize(mainLightDir));
lightGradient = smoothstep(-.5, 1, lightGradient);
float ao = GetScreenSpaceAmbientOcclusion(ScreenUV).directAmbientOcclusion;
// Triplanar
//float3 triplanarMask = TriplanarMask(NormalWS, TriplanarBlend);
// Shadow
float shadowMask = lightGradient;//MainLightShadowAtten(PositionWS, NormalWS, .8);
float shadowAtten = Remap(0, 1, .6, 1, shadowMask);
// Grain
float grain = ValueNoise3DLOD(PositionWS, scaledPositionOS, 600, 3.5);
float grainMin = lerp(.4, .8, shadowMask);
float grainAtten = Remap(0, 1, grainMin, 1, grain);
// Dirt
float dirt = FBMNoise3D(scaledPositionOS, 1, 3, 2, .5);
float dirtMin = lerp(.2, .7, shadowMask);
float dirtAtten = Remap(0, 1, dirtMin, 1, dirt);
/*// Stripes
float StripesScale = StripesDensity;
/*float StripesScale = StripesDensity;
//float StripesWidth = .5;
//float StripesSmooth = .05;
// Octahedral face normals (4 faces + 3 world axes)
float3 octaFaceNormals[7] = {
float3 octaFaceNormals[7] =
{
normalize(float3(1, 1, 1)),
normalize(float3(-1, 1, 1)),
normalize(float3(1, -1, 1)),
@@ -140,11 +94,17 @@ void EnkiBillal_float
{
float d = abs(dot(NormalWS, octaFaceNormals[i]));
octaMask[i] = pow(saturate(d), sharpness);
if (d > hardMax) { hardMax = d; hardIdx = i; }
if (d > hardMax)
{
hardMax = d;
hardIdx = i;
}
}
float maskSum = 0;
for (int i = 0; i < 7; i++) maskSum += octaMask[i];
for (int i = 0; i < 7; i++) octaMask[i] /= max(maskSum, 0.0001);
for (int i = 0; i < 7; i++)
maskSum += octaMask[i];
for (int i = 0; i < 7; i++)
octaMask[i] /= max(maskSum, 0.0001);
float hardFactor = smoothstep(.9, 1, TriplanarBlend);
for (int i = 0; i < 7; i++)
{
@@ -154,8 +114,8 @@ void EnkiBillal_float
// Patern
float3 stripesPos = PositionWS;
float3 stripesPos0 = stripesPos;// + PerlinColor3D(PositionWS, .63) * .10;
float3 stripesPos1 = stripesPos;// + PerlinColor3D(PositionWS, 5.7) * .02;
float3 stripesPos0 = stripesPos; // + PerlinColor3D(PositionWS, .63) * .10;
float3 stripesPos1 = stripesPos; // + PerlinColor3D(PositionWS, 5.7) * .02;
float width = StripesWidth; // * .5;
float smooth = max(StripesSmooth, 1e-5);
@@ -190,6 +150,169 @@ void EnkiBillal_float
float stripes = max(stripes0, stripes1 * .5);
stripes *= smoothstep(.5, 0, lightGradient) * smoothstep(.97, .93, ao);*/
return 0;
}
float ProjectionMask(
float3 PositionWS,
float3 CamPositionWS,
float3 RectPositionWS,
float3 RectNormalWS,
float3 RectUpWS,
float2 RectSize,
float Oval)
{
// Build the rectangle local frame
float3 N = normalize(RectNormalWS);
float3 U = normalize(RectUpWS);
float3 R = normalize(cross(N, U)); // Right axis
U = normalize(cross(R, N)); // Recompute Up orthogonally in case RectUpWS is not perfectly perpendicular to RectNormalWS
// Ray from camera toward the fragment
float3 rayDir = normalize(PositionWS - CamPositionWS);
// Ray-plane intersection
// Plane equation: dot(P - RectPositionWS, N) = 0
float denom = dot(rayDir, N);
// Ray is parallel to the plane or facing away — no hit
if (abs(denom) < 1e-6)
return 0;
float t = dot(RectPositionWS - CamPositionWS, N) / denom;
// Intersection must be in front of the camera (t > 0)
// and not beyond the fragment (t <= distance from camera to fragment)
float tFrag = dot(PositionWS - CamPositionWS, rayDir);
if (t < 0.0 || t > tFrag + 1e-4)
return 0;
// Compute the hit point on the plane
float3 hitPoint = CamPositionWS + rayDir * t;
// Project hit point into the rectangle local space
float3 localVec = hitPoint - RectPositionWS;
float localU = dot(localVec, R); // horizontal axis
float localV = dot(localVec, U); // vertical axis
float halfW = RectSize.x * 0.5;
float halfH = RectSize.y * 0.5;
// Corner radius: 0 = rectangle, min(halfW, halfH) = oval
float radius = saturate(Oval) * min(halfW, halfH);
// Rounded rectangle SDF
float2 uv = float2(localU, localV);
float2 ext = float2(halfW, halfH);
float2 q = abs(uv) - ext + radius;
float sdf = length(max(q, 0.0)) - radius;
return sdf <= 0.0 ? 1.0 : 0.0;
}
float SampleProjectionMask(float3 PositionWS, float4x4 M)
{
if (M[0].w == 0)
return 0;
return ProjectionMask(
PositionWS,
M[0].xyz, // CamPositionWS
M[1].xyz, // RectPositionWS
M[3].xyz, // RectNormalWS
M[2].xyz, // RectUpWS
float2(M[2].w, M[3].w), // RectSize
M[1].w // Shape
);
}
void EnkiBillal_float
(
float4 Color,
float2 ScreenUV,
float3 PositionWS,
float3 PositionOS,
float3 NormalWS,
float3 NormalOS,
float3 ObjectScale,
float TriplanarBlend,
float StripesDensity,
float StripesWidth,
float StripesSmooth,
int Step,
float MaskID,
float4x4 Mask0,
float4x4 Mask1,
float4x4 Mask2,
out float3 Output,
out float Alpha
)
{
float3 scaledPositionOS = PositionOS * ObjectScale;
float depth = length(PositionWS - _WorldSpaceCameraPos.xyz);
//Light mainLight = GetMainLight(0);
float3 mainLightDir = normalize(float3(1, -1, 1)); //normalize(float3(0, 13, 8) - PositionWS); //float3(0, -1, 0);//normalize(mainLight.direction);
float lightGradient = dot(normalize(NormalWS), normalize(mainLightDir));
lightGradient = smoothstep(-.5, 1, lightGradient);
float ao = GetScreenSpaceAmbientOcclusion(ScreenUV).directAmbientOcclusion;
// Triplanar
//float3 triplanarMask = TriplanarMask(NormalWS, TriplanarBlend);
// Shadow
float shadowMask = lightGradient;//MainLightShadowAtten(PositionWS, NormalWS, .8);
float shadowAtten = Remap(0, 1, .6, 1, shadowMask);
// Grain
float grain = ValueNoise3DLOD(PositionWS, scaledPositionOS, 600, 3.5);
float grainMin = lerp(.4, .8, shadowMask);
float grainAtten = Remap(0, 1, grainMin, 1, grain);
// Dirt
float dirt = FBMNoise3D(scaledPositionOS, 1, 3, 2, .5);
float dirtMin = lerp(.2, .7, shadowMask);
float dirtAtten = Remap(0, 1, dirtMin, 1, dirt);
// Stripes
// float stripes = WorldStripes();
// Mask
if (Mask2[0].w == 1)
{
float mask = SampleProjectionMask(PositionWS, Mask2);
Alpha = (MaskID <= 1) ? 1 - mask : mask;
}
else if (MaskID == 0)
{
Alpha = 1;
}
else
{
float Active = Mask0[0].w + Mask1[0].w;// + Mask2[0].w;
if (Active > 0)
{
float mask = saturate(
SampleProjectionMask(PositionWS, Mask0) +
SampleProjectionMask(PositionWS, Mask1)// +
//SampleProjectionMask(PositionWS, Mask2)
);
Alpha = (MaskID == 1) ? 1 - mask : mask;
}
else
{
Alpha = (MaskID == 1) ? 0 : 1;
}
}
// Test