diff --git a/Assets/Scripts/HeroCutout.cs b/Assets/Scripts/HeroCutout.cs index 057c044..b44f649 100644 --- a/Assets/Scripts/HeroCutout.cs +++ b/Assets/Scripts/HeroCutout.cs @@ -2,17 +2,32 @@ using UnityEngine; using Unity.Cinemachine; /// -/// Drives the screen-space radial "hero cutout". Every late frame it projects the local player to -/// screen space and feeds that position + a circle radius/feather + the player's eye-space depth into -/// two global shader properties (_HeroCutoutPosRadius, _HeroCutoutDepthOn) that the -/// Custom/HeroCutoutLit shader reads to clip occluder geometry between the camera and the -/// player - punching a soft see-through hole toward the character. +/// 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. /// -/// The effect is only armed while the third-person orbit is the live -/// Cinemachine camera (i.e. Player Control / Paint mode). In first-person, free-cam, or while a menu -/// is open a different vcam is live, so the cutout disarms and occluders render solid. Disarming sets -/// the global "enabled" flag to 0, which also matches the zero-initialised default - so any scene -/// without this driver leaves cutout materials looking exactly like URP/Lit. +/// 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). /// @@ -25,36 +40,86 @@ public class HeroCutout : MonoBehaviour [Tooltip("Game mode manager, used as a fallback source for the live player. Empty = found automatically.")] public GameModeManager modes; - [Header("Circle")] - [Tooltip("Radius of the see-through circle, as a fraction of screen height (0.13 = 13% of height).")] - [Range(0.02f, 0.6f)] public float radius = 0.13f; + [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; - [Tooltip("Soft-edge width of the circle, as a fraction of screen height.")] - [Range(0f, 0.3f)] public float feather = 0.05f; + [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("Metres added to the player's depth so geometry right at the player isn't clipped away.")] - public float depthBias = 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("Vertical offset (m) from the follow target where the circle centres (chest/head height).")] + [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; - static readonly int PosRadiusId = Shader.PropertyToID("_HeroCutoutPosRadius"); - static readonly int DepthOnId = Shader.PropertyToID("_HeroCutoutDepthOn"); + [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(); - if (orbitInput) _orbitCam = orbitInput.GetComponent(); + 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(); + private void OnDisable() + { + Disarm(); + RestoreDeoccluderPullIn(); + } private void LateUpdate() { + EnsureRefs(); + ApplyPullInSuppression(); + Camera cam = Camera.main; if (!cam) { Disarm(); return; } @@ -67,11 +132,42 @@ public class HeroCutout : MonoBehaviour if (!target) { Disarm(); return; } Vector3 heroPos = target.position + Vector3.up * heightOffset; - Vector3 vp = cam.WorldToViewportPoint(heroPos); - if (vp.z <= 0f) { Disarm(); return; } // hero is behind the camera + Vector3 camPos = cam.transform.position; - Shader.SetGlobalVector(PosRadiusId, new Vector4(vp.x, vp.y, radius, Mathf.Max(feather, 1e-4f))); - Shader.SetGlobalVector(DepthOnId, new Vector4(vp.z + depthBias, 1f, 0f, 0f)); + // 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). @@ -90,5 +186,52 @@ public class HeroCutout : MonoBehaviour } /// Clears the "enabled" flag so cutout materials render as plain URP/Lit. - private void Disarm() => Shader.SetGlobalVector(DepthOnId, Vector4.zero); + 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; + } + } } diff --git a/Assets/Unused/HeroCutout.hlsl b/Assets/Unused/HeroCutout.hlsl index 584bc7d..e4464d8 100644 --- a/Assets/Unused/HeroCutout.hlsl +++ b/Assets/Unused/HeroCutout.hlsl @@ -1,20 +1,34 @@ #ifndef HERO_CUTOUT_INCLUDED #define HERO_CUTOUT_INCLUDED -// Screen-space radial "hero cutout": clips occluder fragments that sit inside a circle around the -// on-screen player AND in front of it, punching a clean see-through hole toward the character. +// World-space "hero cutout": clips occluder fragments that lie inside a CONE running from the camera +// (apex) to the player (target). Everything BETWEEN the camera and the target within the cone is +// punched see-through; geometry well behind the target stays solid - so a wall the player stands in +// front of still renders completely normally. The "between vs. behind" split is purely geometric +// (position along the camera->target axis), not a depth-buffer bias. // -// These two globals are driven every frame by HeroCutout.cs (Shader.SetGlobalVector). They live -// OUTSIDE UnityPerMaterial, so the SRP Batcher is unaffected. When the effect is disabled -// (_HeroCutoutDepthOn.y < 0.5, also the zero-initialised default) ApplyHeroCutout early-outs and the -// shader renders exactly like URP/Lit. -// _HeroCutoutPosRadius : xy = hero viewport pos (0..1, bottom-left), z = radius, w = feather -// radius/feather are in normalized viewport-HEIGHT units -// _HeroCutoutDepthOn : x = hero eye-space depth (incl. bias), y = enabled (0/1) -float4 _HeroCutoutPosRadius; -float4 _HeroCutoutDepthOn; +// That far split is FEATHERED on BOTH sides, never a hard plane: the cut fades out smoothly across a +// band of +/- 'depthFeather' metres AROUND the target. So an occluder that crosses the player's depth +// (a shelf board or wall running away from the camera) keeps fading just behind the player instead of +// snapping to solid - no hard straight cut line where a depth plane would slice the surface. Only +// geometry beyond that band (more than depthFeather behind the target) stays fully solid. +// +// Why a cone and not a cylinder: the cone's radius grows linearly with distance from the camera +// (radius = paramRadius * along/len), so its *screen-space* footprint is constant regardless of how +// far the occluder sits from the camera. A constant-world-radius cylinder would project bigger near +// the lens and smaller near the target - a distorted hole. paramRadius is therefore the radius at the +// target plane (metres); the cone tapers to 0 at the camera apex. +// +// Driven every frame by HeroCutout.cs (Shader.SetGlobalVector). These globals live OUTSIDE +// UnityPerMaterial, so the SRP Batcher is unaffected. When disabled (_HeroCutoutTarget.w < 0.5, also +// the zero-initialised default) ApplyHeroCutout early-outs and the shader renders exactly like URP/Lit. +// _HeroCutoutTarget : xyz = player/target world position, w = enabled (0/1) +// _HeroCutoutParams : x = cone radius at the target plane (m), y = edge feather at the target plane (m), +// z = depth feather: half-width (m) of the soft fade band straddling the target +float4 _HeroCutoutTarget; +float4 _HeroCutoutParams; -// Interleaved-gradient noise -> screen-door dither, gives the circle a soft edge with no extra +// Interleaved-gradient noise -> screen-door dither, gives the cone wall a soft edge with no extra // textures or includes. float HeroCutoutDither(float2 positionSS) { @@ -23,24 +37,43 @@ float HeroCutoutDither(float2 positionSS) void ApplyHeroCutout(float4 positionCS) { - if (_HeroCutoutDepthOn.y < 0.5) + if (_HeroCutoutTarget.w < 0.5) return; - // Flip-correct screen UV (matches Camera.WorldToViewportPoint's bottom-left convention). - float2 uv = GetNormalizedScreenSpaceUV(positionCS); - float2 delta = uv - _HeroCutoutPosRadius.xy; - delta.x *= _ScreenParams.x / max(_ScreenParams.y, 1.0); // aspect-correct -> a round circle - float dist = length(delta); + // Reconstruct this fragment's world position from screen UV + device depth. + float2 screenUV = GetNormalizedScreenSpaceUV(positionCS); + float3 posWS = ComputeWorldSpacePosition(screenUV, positionCS.z, UNITY_MATRIX_I_VP); - float radius = _HeroCutoutPosRadius.z; - float feather = max(_HeroCutoutPosRadius.w, 1e-4); - float inside = 1.0 - smoothstep(radius - feather, radius, dist); // 1 inside, 0 outside + float3 camPos = _WorldSpaceCameraPos.xyz; + float3 axis = _HeroCutoutTarget.xyz - camPos; + float len = length(axis); + if (len < 1e-4) + return; + float3 dir = axis / len; - // Only hole geometry between the camera and the hero; anything at/behind the player stays solid. - float eyeDepth = LinearEyeDepth(positionCS.z, _ZBufferParams); - float inFront = step(eyeDepth, _HeroCutoutDepthOn.x); + // Distance along the camera->target axis (metres from the camera). + float along = dot(posWS - camPos, dir); + float depthFeather = max(_HeroCutoutParams.z, 1e-4); + if (along <= 0.0 || along >= len + depthFeather) // behind the camera, or well behind the target -> solid + return; - float cut = inside * inFront; + // Perpendicular distance to the axis -> the cone test. + float3 closest = camPos + dir * along; + float radial = length(posWS - closest); + + // Cone: radius scales with distance from the camera (apex), so the on-screen hole stays the same + // size at every occluder depth. t in (0,1]: 0 at the camera, 1 at the target plane. + float t = along / len; + float radius = _HeroCutoutParams.x * t; + float feather = max(_HeroCutoutParams.y * t, 1e-4); + float radialCut = 1.0 - smoothstep(radius - feather, radius, radial); // 1 inside the cone, 0 outside + + // Soft depth cap instead of a hard plane at the target: the cut fades out across a band straddling + // the target - full strength up to (len - depthFeather), gone by (len + depthFeather). So an occluder + // crossing the player's depth keeps fading just behind them rather than showing a hard straight edge. + float axialCut = 1.0 - smoothstep(len - depthFeather, len + depthFeather, along); + + float cut = radialCut * axialCut; clip(HeroCutoutDither(positionCS.xy) - cut); } #endif