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;
}
}
}