Files
chameleon/Assets/Scripts/HeroCutout.cs
T
adminandClaude Opus 4.8 b22d2ed342 Scale the hero cutout with the player's PlayerScaler
The cutout cone's radius and feather are now multiplied by the hero target's
uniform world scale (lossyScale.x, the same body-scale source the orbit input
uses). The authored values are the scale-1 sizes, so the see-through hole and
its soft edge keep the same on-screen proportion for a small hider or a large
hunter, and a mid-match conversion tracks live since lossyScale is read every
frame.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:32:25 +02:00

247 lines
12 KiB
C#

using UnityEngine;
using Unity.Cinemachine;
/// <summary>
/// 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 (<c>_HeroCutoutTarget</c>,
/// <c>_HeroCutoutParams</c>) that the <c>Custom/HeroCutoutLit</c> 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 (<see cref="depthFeather"/>) 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 <see cref="CinemachineCamera"/> 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).
/// </summary>
[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<ThirdPersonOrbitInput>(FindObjectsInactive.Include);
if (!modes) modes = FindFirstObjectByType<GameModeManager>();
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<CinemachineBrain>();
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.
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));
}
/// <summary>
/// Single ray, camera -> player. Returns true when a non-ignored collider blocks the view before
/// the ray reaches the player (stopped short by <see cref="minDistanceFromTarget"/> 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.
/// </summary>
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);
}
/// <summary>True only when the orbit vcam is the live Cinemachine camera (third-person modes).</summary>
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;
}
/// <summary>Clears the "enabled" flag so cutout materials render as plain URP/Lit.</summary>
private void Disarm() => Shader.SetGlobalVector(TargetId, Vector4.zero);
/// <summary>
/// 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.
/// </summary>
private void EnsureRefs()
{
if (!_orbitCam && orbitInput) _orbitCam = orbitInput.GetComponent<CinemachineCamera>();
if (!_deoccluderResolved && _orbitCam)
{
_deoccluder = _orbitCam.GetComponent<CinemachineDeoccluder>();
if (_deoccluder) _deoccluderOriginalAvoid = _deoccluder.AvoidObstacles.Enabled;
_deoccluderResolved = true;
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>Restores the deoccluder's authored pull-in if this driver had suppressed it.</summary>
private void RestoreDeoccluderPullIn()
{
if (_pullInSuppressed && _deoccluder)
{
_deoccluder.AvoidObstacles.Enabled = _deoccluderOriginalAvoid;
_pullInSuppressed = false;
}
}
}