using UnityEngine; using Unity.Cinemachine; /// /// Drives the world-space "hero cutout": occluder geometry inside a cone from the third-person camera /// (apex) to the player is clipped away, punching a see-through funnel toward the character. Each late /// frame this feeds two global shader properties (_HeroCutoutTarget, _HeroCutoutParams) /// that Custom/HeroCutoutLit reads. /// /// Arming is deliberately simple: ONE ray from the camera toward the player - blocked = armed, clear /// view = occluders stay solid. The cut volume lives entirely in the shader: everything between camera /// and player (within the cone) is cut, geometry behind the player stays solid, and the far end fades /// across a soft depth band () instead of a hard plane. The cone radius grows /// with camera distance, so the hole keeps a constant on-screen size at every occluder depth. /// /// Active only while the third-person orbit vcam is the live Cinemachine camera; disarming clears the /// global flag (also the zero-initialised default), so scenes without this driver render cutout /// materials as plain URP/Lit. Replaces the Cinemachine Deoccluder pull-in: the camera holds its orbit /// distance and the obstacle goes see-through. Attach to a persistent scene object. /// [DisallowMultipleComponent] public class HeroCutout : MonoBehaviour { [Tooltip("Orbit camera input whose CinemachineCamera must be live for the cutout to arm. Empty = found automatically.")] public ThirdPersonOrbitInput orbitInput; [Tooltip("Game mode manager, used as a fallback source for the live player. Empty = found automatically.")] public GameModeManager modes; [Tooltip("Switch off the orbit camera's Cinemachine Deoccluder obstacle avoidance at runtime so the " + "camera holds its orbit distance and this cutout handles occlusion - instead of the camera " + "pulling in toward the player. The scene's Deoccluder asset is left untouched; only its live " + "pull-in is disabled (and restored if this driver is disabled).")] public bool replaceDeoccluderPullIn = true; [Header("Cutout cone")] [Tooltip("Radius of the see-through cone AT THE PLAYER, in metres, AT PLAYER SCALE 1. The live radius is " + "multiplied by the hero target's uniform world scale (PlayerScaler) so the hole keeps the same " + "on-screen size for a small hider or a large hunter. The cone also tapers to nothing at the " + "camera, so the hole keeps a constant on-screen size at every occluder depth.")] [Range(0.05f, 3f)] public float radius = 0.5f; [Tooltip("Soft-edge width of the cone wall at the player, in metres, AT PLAYER SCALE 1 (also tapers " + "toward the camera). Like the radius, the live value is multiplied by the hero target's uniform " + "world scale (PlayerScaler) so the soft edge keeps the same on-screen proportion at any size.")] [Range(0f, 1f)] public float feather = 0.15f; [Tooltip("Soft depth band (m) straddling the player: instead of stopping at a hard plane at the " + "player's depth, the cut fades out across +/- this distance around the player, so an occluder " + "running past the player (a shelf board) fades just behind them too instead of showing a hard " + "straight cut line. Geometry beyond this band stays fully solid.")] [Range(0.01f, 2f)] public float depthFeather = 0.5f; [Tooltip("Vertical offset (m) from the follow target the cone aims at (chest/head height).")] public float heightOffset = 0f; [Header("Occlusion ray (camera -> player)")] [Tooltip("Layers that count as occluders. Leave empty (Nothing) to fall back to all default raycast layers.")] public LayerMask occluderMask = ~0; [Tooltip("Collider tag ignored by the ray (the player's own colliders). Must be an existing tag; " + "empty = ignore nothing.")] public string ignoreTag = "Player"; [Tooltip("Metres the ray stops short of the player. Keep at 0 so a wall the player stands right " + "behind still arms the cut (everything between camera and player counts). The ray already " + "ends AT the player, so geometry behind them is never hit regardless. Raise this only if a " + "low camera grazes the floor at the player's feet (better fixed via Height Offset).")] [Range(0f, 1.5f)] public float minDistanceFromTarget = 0f; [Tooltip("Seconds the cutout stays armed after the view clears again, to stop flicker at occluder edges.")] public float occlusionHold = 0.1f; static readonly int TargetId = Shader.PropertyToID("_HeroCutoutTarget"); static readonly int ParamsId = Shader.PropertyToID("_HeroCutoutParams"); private CinemachineCamera _orbitCam; private CinemachineBrain _brain; private CinemachineDeoccluder _deoccluder; private bool _deoccluderResolved; private bool _deoccluderOriginalAvoid; private bool _pullInSuppressed; private float _lastOccludedTime = -999f; private void Awake() { if (!orbitInput) orbitInput = FindFirstObjectByType(FindObjectsInactive.Include); if (!modes) modes = FindFirstObjectByType(); EnsureRefs(); // CompareTag throws on an undefined tag - validate once so the per-frame ray never does. if (!string.IsNullOrEmpty(ignoreTag)) { try { _ = gameObject.CompareTag(ignoreTag); } catch { Debug.LogWarning($"[HeroCutout] Ignore Tag '{ignoreTag}' is not defined; the ray will ignore nothing.", this); ignoreTag = string.Empty; } } } private void OnDisable() { Disarm(); RestoreDeoccluderPullIn(); } private void LateUpdate() { EnsureRefs(); ApplyPullInSuppression(); Camera cam = Camera.main; if (!cam) { Disarm(); return; } if (!_brain || _brain.gameObject != cam.gameObject) _brain = cam.GetComponent(); if (!IsThirdPersonLive()) { Disarm(); return; } Transform target = ResolveHeroTarget(); if (!target) { Disarm(); return; } Vector3 heroPos = target.position + Vector3.up * heightOffset; Vector3 camPos = cam.transform.position; // Body-relative cone: the authored radius is the scale-1 size, scaled by the hero's uniform world // size so the hole stays the same on screen for a small hider or a large hunter. lossyScale captures // the full hierarchy (PlayerScaler drives localScale); reading it live tracks a mid-match conversion. float bodyScale = Mathf.Max(1e-4f, target.lossyScale.x); // Only punch the tube when something actually stands between the player and the camera; with a // clear view the occluders stay solid. A short hold after the last blocked frame avoids flicker // when the single ray grazes an occluder edge. if (IsViewBlocked(camPos, heroPos)) _lastOccludedTime = Time.time; if (Time.time - _lastOccludedTime > Mathf.Max(0f, occlusionHold)) { Disarm(); return; } // Same hero point feeds the ray and the cone axis, so the gate and the cut volume agree. _armed = true; Shader.SetGlobalVector(TargetId, new Vector4(heroPos.x, heroPos.y, heroPos.z, 1f)); Shader.SetGlobalVector(ParamsId, new Vector4(Mathf.Max(0f, radius * bodyScale), Mathf.Max(1e-4f, feather * bodyScale), Mathf.Max(1e-4f, depthFeather), 0f)); } /// /// Single ray, camera -> player. Returns true when a non-ignored collider blocks the view before /// the ray reaches the player (stopped short by so the wall the /// player stands against / the floor at their feet never counts). Anything "between" the camera and /// the player is necessarily the first thing the ray hits, so checking the first hit is enough. /// private bool IsViewBlocked(Vector3 camPos, Vector3 heroPos) { Vector3 delta = heroPos - camPos; float dist = delta.magnitude; if (dist <= 1e-3f) return false; Vector3 dir = delta / dist; float maxDist = dist - Mathf.Clamp(minDistanceFromTarget, 0f, dist); if (maxDist <= 1e-3f) return false; int mask = occluderMask.value != 0 ? occluderMask.value : Physics.DefaultRaycastLayers; if (!Physics.Raycast(camPos, dir, out RaycastHit hit, maxDist, mask, QueryTriggerInteraction.Ignore)) return false; // First (closest) hit decides: if it's the ignored player, nothing real is in between. return string.IsNullOrEmpty(ignoreTag) || !hit.collider.CompareTag(ignoreTag); } /// True only when the orbit vcam is the live Cinemachine camera (third-person modes). private bool IsThirdPersonLive() { if (!_orbitCam || !_brain) return false; var live = _brain.ActiveVirtualCamera as CinemachineCamera; return live && live == _orbitCam; } private Transform ResolveHeroTarget() { if (_orbitCam && _orbitCam.Follow) return _orbitCam.Follow; if (modes && modes.Player) return modes.Player.transform; return null; } private bool _armed; // change-guard: Disarm runs every non-armed frame, so it only writes the transition /// Clears the "enabled" flag so cutout materials render as plain URP/Lit. private void Disarm() { if (!_armed) return; _armed = false; Shader.SetGlobalVector(TargetId, Vector4.zero); } /// /// Lazily resolves the orbit vcam and its Cinemachine Deoccluder (they may live in a scene that /// loads after this driver). Captures the deoccluder's authored obstacle-avoidance state once so it /// can be restored later. /// private void EnsureRefs() { if (!_orbitCam && orbitInput) _orbitCam = orbitInput.GetComponent(); if (!_deoccluderResolved && _orbitCam) { _deoccluder = _orbitCam.GetComponent(); if (_deoccluder) _deoccluderOriginalAvoid = _deoccluder.AvoidObstacles.Enabled; _deoccluderResolved = true; } } /// /// Switches the deoccluder's obstacle avoidance (the camera pull-in) off while this driver replaces /// it with the cutout, or restores the authored state when the replacement is turned off. Only /// toggles on transitions, so it never fights a value changed elsewhere mid-frame. /// private void ApplyPullInSuppression() { if (!_deoccluder) return; if (replaceDeoccluderPullIn && !_pullInSuppressed) { _deoccluder.AvoidObstacles.Enabled = false; _pullInSuppressed = true; } else if (!replaceDeoccluderPullIn && _pullInSuppressed) { _deoccluder.AvoidObstacles.Enabled = _deoccluderOriginalAvoid; _pullInSuppressed = false; } } /// Restores the deoccluder's authored pull-in if this driver had suppressed it. private void RestoreDeoccluderPullIn() { if (_pullInSuppressed && _deoccluder) { _deoccluder.AvoidObstacles.Enabled = _deoccluderOriginalAvoid; _pullInSuppressed = false; } } }