using UnityEngine; using Unity.Cinemachine; /// /// Drives the world-space "hero cutout": occluder geometry that sits inside a cone running from the /// third-person camera (apex) to the player is clipped away, punching a clean see-through funnel toward /// the character. Every late frame this feeds two global shader properties (_HeroCutoutTarget, /// _HeroCutoutParams) that the Custom/HeroCutoutLit shader reads. /// /// Arming is deliberately simple: a single ray is cast FROM the camera TOWARD the player. If it hits /// world geometry before reaching the player, the cut is armed; with a clear line of sight the /// occluders stay solid. There is no multi-sample / silhouette estimation - one ray, one answer. /// /// The cut volume is defined entirely in the shader as a cone along the camera->player axis: everything /// BETWEEN camera and player (within the cone) is cut; geometry well behind the player stays solid - so a /// wall the player stands in front of renders completely normally. The far end fades out across a soft /// depth band () straddling the player rather than snapping off at a hard /// plane, so an occluder running past the player fades just behind them with no hard cut line. The cone's /// radius grows with distance from the camera, so the hole keeps a constant on-screen size regardless of /// occluder depth. /// /// The effect is only active while the third-person orbit is the live /// Cinemachine camera (Player Control / Paint mode). In first-person, free-cam or while a menu is open /// a different vcam is live and the cutout disarms. Disarming clears the global "enabled" flag (also /// the zero-initialised default), so any scene without this driver - or any frame with a clear view - /// leaves cutout materials looking exactly like URP/Lit. /// /// This replaces the camera pulling in toward the player (Cinemachine Deoccluder): instead of closing /// the distance to dodge an obstacle, the camera holds its orbit distance and the obstacle is punched /// see-through. /// /// Attach to a persistent scene object (e.g. the GameModeManager host). /// [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. The cone 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 (also tapers toward the camera).")] [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; // 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. Shader.SetGlobalVector(TargetId, new Vector4(heroPos.x, heroPos.y, heroPos.z, 1f)); Shader.SetGlobalVector(ParamsId, new Vector4(Mathf.Max(0f, radius), Mathf.Max(1e-4f, feather), 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; } /// Clears the "enabled" flag so cutout materials render as plain URP/Lit. private void Disarm() => 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; } } }