Drive the hero cutout from a single camera-to-target ray
Replace the multi-sample silhouette probe with one camera->target raycast: any world geometry in the line of sight arms the cut, a clear line keeps occluders solid. The shader carves a world-space cone (apex at the camera, radius growing toward the target) so the screen-space hole stays a constant size regardless of occluder depth, with a soft radial fade plus a symmetric axial fade around the target so geometry just behind the player feathers out instead of hard-cutting along a plane. Anything well past the target stays solid. Also suppresses the deoccluder pull-in so the camera holds its orbit distance and the cut takes over. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
1aa1f6d869
commit
06f5f33e63
2 files changed
+228
-52
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user