Entities/VisionOccluder.cs
using System;
using System.Collections.Generic;
using Sandbox;
namespace BlockParty;
/// <summary>How a vision blocker covers the view.</summary>
public enum VisionBlockMode
{
/// <summary>Cover everything directly behind the blocker from the player (a hard shadow).</summary>
Umbra,
/// <summary>Cover the whole arena EXCEPT a narrow widening cone (less than 90°) between the blocker and
/// the player (plus the umbra behind it) — so the player sees only the corridor toward the blocker.</summary>
Spotlight,
}
/// <summary>One vision blocker for a frame: its world rect and how it covers.</summary>
public readonly record struct VisionBlocker( RectF Rect, VisionBlockMode Mode );
/// <summary>
/// 2D line-of-sight cover. Given a set of vision-blocking rectangles and the player's position, it
/// renders an opaque "shadow" over the area behind each blocker (relative to the player), so the
/// player can't see what's on the far side of a blocker. Purely cosmetic — it reads only the render-
/// frame player position and the blocker rects, consumes no <see cref="Rng"/> and runs outside the
/// deterministic fixed step, so it has zero effect on the sim / replays.
///
/// The technique is the 2D analogue of a shadow volume (the reusable idea from the killbox
/// <c>ViewOccluder</c>): for each blocker we extrude its edges that FACE AWAY from the player out to a
/// large distance, forming the umbra, clip it to the arena, and fill it. Only back-facing edges are
/// cast so a single convex blocker's umbra tiles with no self-overlap. Geometry is rebuilt every frame
/// (the player moves) and drawn through a <see cref="SceneCustomObject"/> in the opaque pass with a
/// small unlit vertex-colour shader (depth-write hides the gameplay behind the blocker).
/// </summary>
public sealed class VisionOccluder : Component
{
/// <summary>Supplies the current blockers each frame by appending them into the provided list. A
/// delegate (not a cached list) so the caller can supply live, moving blockers (vision-blocking
/// blocks); fill-a-list rather than returning an IEnumerable so the every-frame rebuild doesn't
/// allocate an iterator.</summary>
public Action<List<VisionBlocker>> BlockerSource { get; set; }
/// <summary>The viewer whose position the shadows are cast from.</summary>
public Player Player { get; set; }
public Func<Player> PlayerSource { get; set; }
/// <summary>Final cover colour, in LINEAR space — the shader outputs it directly (no conversion),
/// so pass the same linear colour the walls use to match them exactly.</summary>
public Color Cover { get; set; } = Color.Black;
// Extrusion distance for the umbra edges. The arena is 240 across, so any fixed distance well
// beyond its diagonal (~340) guarantees the shadow reaches past the far wall from any point; the
// geometry is then clipped to the arena rect so it never bleeds into the pillarbox.
private const float EXTRUDE = 2000f;
// Phase-2 spotlight cone half-angle (radians)
private const float SPOTLIGHT_HALF_ANGLE = 0.35f;
private SceneCustomObject _so;
private Material _material;
private readonly List<VisionBlocker> _blockers = new();
private readonly List<Vertex> _verts = new();
// Scratch buffers for the per-edge umbra polygon and its arena clip (reused each frame).
private readonly List<Vector2> _poly = new();
private readonly List<Vector2> _clip = new();
private readonly List<Vector2> _clipTmp = new();
// Scratch buffers for the phase-2 spotlight convex hull.
private readonly List<Vector2> _hullPts = new();
private readonly List<Vector2> _hull = new();
protected override void OnEnabled()
{
_material = Material.FromShader( "shaders/vision_cover.shader" );
_so = new SceneCustomObject( Scene.SceneWorld )
{
// Identity transform: the vertices are already in world space (the shader projects them
// directly), and the object isn't sorted by transform since it renders in the opaque pass.
Transform = global::Transform.Zero,
RenderOverride = OnRender,
};
// A custom scene object must declare which render pass it belongs to, or it isn't drawn into
// the scene. Render OPAQUE (depth-write): the opaque pass composites reliably, and the depth it
// writes at the cover's Z (above the walls) hides the gameplay AND wall spikes behind the
// blocker. Nearer layers (portal flash, HUD text) still draw over it.
_so.Flags.IsOpaque = true;
_so.Flags.IsTranslucent = false;
_so.Flags.CastShadows = false;
}
protected override void OnDisabled()
{
_so?.Delete();
_so = null;
}
protected override void OnUpdate()
{
RebuildGeometry();
}
/// <summary>Refresh the cover from the current player and blockers. Call after configuring a new
/// occluder so its first render is covered even before its first update.</summary>
public void RebuildGeometry()
{
_verts.Clear();
var player = PlayerSource?.Invoke() ?? Player;
if ( !player.IsValid() || BlockerSource is null )
return;
_blockers.Clear();
BlockerSource( _blockers );
if ( _blockers.Count == 0 )
return;
Vector2 light = player.Pos;
float z = Globals.DepthToZ( Globals.DEPTH_VISION_COVER );
foreach ( var b in _blockers )
{
if ( b.Mode == VisionBlockMode.Spotlight )
AppendSpotlight( b.Rect, light, z );
else
AppendBlockerShadow( b.Rect, light, z );
}
}
/// <summary>Append the umbra of one rectangular blocker: extrude each of its back-facing edges away
/// from the light, clip the resulting quad to the arena, and fill it. This is the phase-1 cover; the
/// phase-2 "spotlight" is handled separately by <see cref="AppendSpotlight"/>.</summary>
private void AppendBlockerShadow( RectF r, Vector2 light, float z )
{
// Light inside the blocker: the silhouette is undefined and there's nothing meaningful to
// occlude, so skip it.
if ( light.x > r.Left && light.x < r.Right && light.y > r.Bottom && light.y < r.Top )
return;
// Corners, counter-clockwise (with Y up). Edge i runs corner[i] -> corner[i+1]; for a CCW
// winding its outward normal is (dy, -dx).
Span<Vector2> corners = stackalloc Vector2[4]
{
r.BottomLeft,
r.BottomRight,
r.TopRight,
r.TopLeft,
};
for ( int i = 0; i < 4; i++ )
{
Vector2 a = corners[i];
Vector2 b = corners[( i + 1 ) % 4];
Vector2 edge = b - a;
Vector2 outward = new Vector2( edge.y, -edge.x );
Vector2 mid = ( a + b ) * 0.5f;
// Only edges facing away from the light cast the umbra; a convex blocker's back-facing
// edges tile it with no overlap.
if ( Vector2.Dot( outward, mid - light ) <= 0f )
continue;
_poly.Clear();
_poly.Add( a );
_poly.Add( b );
_poly.Add( Extrude( b, light ) );
_poly.Add( Extrude( a, light ) );
ClipToArena( _poly, _clip );
AppendPolygonFan( _clip, z );
}
}
/// <summary>Push <paramref name="p"/> along the ray from <paramref name="light"/> through it to a
/// point well past the arena bounds (the shadow's far edge on the corner's silhouette ray).</summary>
private static Vector2 Extrude( Vector2 p, Vector2 light )
{
Vector2 dir = p - light;
float len = dir.Length;
if ( len < 0.0001f )
return p; // degenerate (light exactly on the corner) — harmless zero-area contribution
return p + dir / len * EXTRUDE;
}
/// <summary>Fan-triangulate a convex polygon into the vertex buffer at depth <paramref name="z"/>.</summary>
private void AppendPolygonFan( List<Vector2> poly, float z )
{
if ( poly.Count < 3 )
return;
Color32 col = Cover;
Vertex V( Vector2 p ) => new Vertex( new Vector3( p.x, p.y, z ), col );
for ( int k = 1; k + 1 < poly.Count; k++ )
{
_verts.Add( V( poly[0] ) );
_verts.Add( V( poly[k] ) );
_verts.Add( V( poly[k + 1] ) );
}
}
/// <summary>Sutherland–Hodgman clip of a convex polygon to the arena rect [0,W]x[0,H]. Keeps the
/// cover strictly inside the arena so it can render above the walls (covering wall spikes) without
/// bleeding into the pillarbox. Result is written to <paramref name="result"/>.</summary>
private void ClipToArena( List<Vector2> input, List<Vector2> result )
{
ClipHalfPlane( input, result, 0 ); // left: x >= 0
ClipHalfPlane( result, _clipTmp, 1 ); // right: x <= W
ClipHalfPlane( _clipTmp, result, 2 ); // bottom: y >= 0
ClipHalfPlane( result, _clipTmp, 3 ); // top: y <= H
result.Clear();
result.AddRange( _clipTmp );
}
private static bool Inside( Vector2 p, int edge ) => edge switch
{
0 => p.x >= 0f,
1 => p.x <= Arena.WIDTH,
2 => p.y >= 0f,
_ => p.y <= Arena.HEIGHT,
};
private static Vector2 Intersect( Vector2 a, Vector2 b, int edge )
{
float t = edge switch
{
0 => ( 0f - a.x ) / ( b.x - a.x ),
1 => ( Arena.WIDTH - a.x ) / ( b.x - a.x ),
2 => ( 0f - a.y ) / ( b.y - a.y ),
_ => ( Arena.HEIGHT - a.y ) / ( b.y - a.y ),
};
return a + ( b - a ) * t;
}
private static void ClipHalfPlane( List<Vector2> src, List<Vector2> dst, int edge )
{
dst.Clear();
int n = src.Count;
if ( n == 0 )
return;
Vector2 prev = src[n - 1];
bool prevIn = Inside( prev, edge );
for ( int i = 0; i < n; i++ )
{
Vector2 cur = src[i];
bool curIn = Inside( cur, edge );
if ( curIn )
{
if ( !prevIn )
dst.Add( Intersect( prev, cur, edge ) );
dst.Add( cur );
}
else if ( prevIn )
{
dst.Add( Intersect( prev, cur, edge ) );
}
prev = cur;
prevIn = curIn;
}
}
/// <summary>Phase-2 "spotlight": the visible region is the block PLUS a cone opening from it toward
/// (and past) the player; everything else in the arena is covered. Built as the convex hull of the
/// block's four corners and two far points along the cone edges, whose arena complement is filled.
/// This REPLACES the phase-1 umbra (the complement already covers behind the block).</summary>
private void AppendSpotlight( RectF r, Vector2 light, float z )
{
// Player inside the blocker: nothing sensible to draw.
if ( light.x > r.Left && light.x < r.Right && light.y > r.Bottom && light.y < r.Top )
return;
Vector2 center = new Vector2( ( r.Left + r.Right ) * 0.5f, ( r.Bottom + r.Top ) * 0.5f );
Vector2 toPlayer = light - center;
if ( toPlayer.Length < 0.0001f )
return;
Vector2 axis = toPlayer.Normal; // block -> player
// Visible hull = the block's four corners (keeps the whole block visible) + two far points along
// the cone edges, so the visible region widens from the block toward the player and reaches past
// it to the arena edge.
_hullPts.Clear();
_hullPts.Add( r.BottomLeft );
_hullPts.Add( r.BottomRight );
_hullPts.Add( r.TopRight );
_hullPts.Add( r.TopLeft );
_hullPts.Add( center + Rotate( axis, SPOTLIGHT_HALF_ANGLE ) * EXTRUDE );
_hullPts.Add( center + Rotate( axis, -SPOTLIGHT_HALF_ANGLE ) * EXTRUDE );
ConvexHull( _hullPts, _hull );
if ( _hull.Count < 3 )
return;
// Cover = arena minus the visible hull = union of each CCW hull edge's OUTER half-plane.
int n = _hull.Count;
for ( int i = 0; i < n; i++ )
{
Vector2 a = _hull[i];
Vector2 b = _hull[( i + 1 ) % n];
Vector2 outN = new Vector2( b.y - a.y, -( b.x - a.x ) ); // outward normal for a CCW polygon
BuildArenaPoly( _poly );
ClipPolyHalf( _poly, _clip, a, outN );
AppendPolygonFan( _clip, z );
}
}
private void BuildArenaPoly( List<Vector2> dst )
{
dst.Clear();
dst.Add( new Vector2( 0f, 0f ) );
dst.Add( new Vector2( Arena.WIDTH, 0f ) );
dst.Add( new Vector2( Arena.WIDTH, Arena.HEIGHT ) );
dst.Add( new Vector2( 0f, Arena.HEIGHT ) );
}
/// <summary>Clip a convex polygon to the half-plane { p : dot(p - linePoint, normal) >= 0 }.</summary>
private static void ClipPolyHalf( List<Vector2> src, List<Vector2> dst, Vector2 linePoint, Vector2 normal )
{
dst.Clear();
int n = src.Count;
if ( n == 0 )
return;
Vector2 prev = src[n - 1];
float prevD = Vector2.Dot( prev - linePoint, normal );
for ( int i = 0; i < n; i++ )
{
Vector2 cur = src[i];
float curD = Vector2.Dot( cur - linePoint, normal );
bool curIn = curD >= 0f;
bool prevIn = prevD >= 0f;
if ( curIn )
{
if ( !prevIn )
dst.Add( prev + ( cur - prev ) * ( prevD / ( prevD - curD ) ) );
dst.Add( cur );
}
else if ( prevIn )
{
dst.Add( prev + ( cur - prev ) * ( prevD / ( prevD - curD ) ) );
}
prev = cur;
prevD = curD;
}
}
// Named static method, not a lambda in a static field (those break hotload).
private static int HullSort( Vector2 p, Vector2 q ) => p.x != q.x ? p.x.CompareTo( q.x ) : p.y.CompareTo( q.y );
/// <summary>Convex hull (Andrew's monotone chain) of <paramref name="pts"/> into
/// <paramref name="hull"/>, ordered counter-clockwise. Mutates (sorts) <paramref name="pts"/>.</summary>
private static void ConvexHull( List<Vector2> pts, List<Vector2> hull )
{
pts.Sort( HullSort );
hull.Clear();
int n = pts.Count;
for ( int i = 0; i < n; i++ )
{
while ( hull.Count >= 2 && HullCross( hull[hull.Count - 2], hull[hull.Count - 1], pts[i] ) <= 0f )
hull.RemoveAt( hull.Count - 1 );
hull.Add( pts[i] );
}
int lower = hull.Count + 1;
for ( int i = n - 2; i >= 0; i-- )
{
while ( hull.Count >= lower && HullCross( hull[hull.Count - 2], hull[hull.Count - 1], pts[i] ) <= 0f )
hull.RemoveAt( hull.Count - 1 );
hull.Add( pts[i] );
}
hull.RemoveAt( hull.Count - 1 ); // last point repeats the first
}
private static float HullCross( Vector2 o, Vector2 a, Vector2 b )
=> ( a.x - o.x ) * ( b.y - o.y ) - ( a.y - o.y ) * ( b.x - o.x );
private static Vector2 Rotate( Vector2 v, float radians )
{
float c = MathF.Cos( radians ), s = MathF.Sin( radians );
return new Vector2( v.x * c - v.y * s, v.x * s + v.y * c );
}
private void OnRender( SceneObject o )
{
if ( _verts.Count == 0 || _material is null )
return;
Graphics.Draw( _verts, _verts.Count, _material, primitiveType: Graphics.PrimitiveType.Triangles );
}
}