Iteration 4 over Assets/Scripts (no component/file splits, no Unity assets touched): Dedup: - Route inline cursor-release, material guards, Ctrl-held and undo/redo input through existing InputUtil / SetMatFloat helpers - Add ungated InputUtil.KeyPressedThisFrame; GameplayKeyPressed builds on it - Unify FindDeep/FindDeepChild into GeometryUtil.FindDeep Dead code / stale docs: - Remove unused code-gen bake path (HsvColorPicker.Build, PaintHud factory) - Drop unreferenced symbols (SubPhaseChanged event, LocalPhaseDuration, IsLocalSceneMain, MouseSensitivity.Changed, IsPaused, InSession, NetworkControlled, PreviousPose, JointCount, voxel-count accessors) - Fix incorrect PlayerRole / PaintHud / Pick / PlayerPose docs Perf / robustness (behaviour-neutral): - ConcealmentMeter.Evaluate: reuse vertex/topology buffers, two-pointer crossing count - BuildMeshCaches: alloc-free vertex copy, cached topology/UVs - Guard all PlayerPaintSync LogSync sites behind logSync - Cache LocalViewerIsHunter / controller / CanShoot / TypingGuard lookups; dedup LocalIsHunter with MatchHud - Change-guard RefreshEmissionColor and HeroCutout.Disarm - Cache scene/countdown string comparisons per frame - Grow NonAlloc buffers and log-once on saturation Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
242 lines
11 KiB
C#
242 lines
11 KiB
C#
using UnityEngine;
|
|
using Unity.Cinemachine;
|
|
|
|
/// <summary>
|
|
/// 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 (<c>_HeroCutoutTarget</c>, <c>_HeroCutoutParams</c>)
|
|
/// that <c>Custom/HeroCutoutLit</c> 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 (<see cref="depthFeather"/>) 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.
|
|
/// </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.
|
|
_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));
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
private bool _armed; // change-guard: Disarm runs every non-armed frame, so it only writes the transition
|
|
|
|
/// <summary>Clears the "enabled" flag so cutout materials render as plain URP/Lit.</summary>
|
|
private void Disarm()
|
|
{
|
|
if (!_armed) return;
|
|
_armed = false;
|
|
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;
|
|
}
|
|
}
|
|
}
|