Files
sbox-public/engine/Sandbox.Engine/Systems/Render/Shadows/ShadowMapper.cs
Sam Pavlovic 2388933716 Fix Hammer Shadows Not Rendering Properly (#5334)
* Fix hammer lights not casting shadows and having weird lighting after they became components

* Hammer lights have cone mask from the lightmap itself, we can take advantage of that and tighten the cone for sharper shadows

* Depth buffer bias should track cone width and map resolution, not light radius

A range-5000 light could get ~25x bias and heavy peter-panning, same for overly-wide spots

* Hammer lights have no concept of "hardness" - let's make them reasonably sharp by default a bit like previous behaviour

* Tweaks

* format
2026-07-14 05:45:03 +02:00

321 lines
11 KiB
C#

using NativeEngine;
using System.Numerics;
using System.Runtime.CompilerServices;
namespace Sandbox.Rendering;
/// <summary>
/// ShadowMapper
/// It is owned by a CLightBinnerStandard in c++ which are pooled and reinitialized with CLightBinnerStandard::InitForView
/// We then also get called by CLightBinnerStandard::UploadLightingToGPU
/// </summary>
internal partial class ShadowMapper
{
[ConVar( "r.shadows.max", Min = -1, Max = 256, Help = "Maximum number of shadow-casting local lights. Lights are sorted by screen size, least important are culled first." )]
public static int MaxShadows { get; set; } = 256;
[ConVar( "r.shadows.maxresolution", Min = 128, Max = 4096, Help = "Max texture size (square) for a projected light shadow map, higher is better but uses more vram, these are scaled automatically too." )]
public static int MaxResolution { get; set; } = 1024; // Low/Med=512, High=1024, Very High=2048
[ConVar( "r.shadows.quality", Min = 0, Max = 4, Help = "What filtering to use, higher is more GPU intensive. 0 = Off, 1 = Low, 2 = Med, 3 = High, 4 = Experimental Penumbra Shadows" )]
public static int ShadowFilter { get; set; } = 3;
[ConVar( "r.shadows.csm.maxcascades", Min = 1, Max = 4, Help = "Maximum number of cascades for directional light shadows." )]
public static int MaxCascades { get; set; } = 4;
[ConVar( "r.shadows.csm.maxresolution", Min = 512, Max = 8192, Help = "Maximum resolution for each cascade shadow map." )]
public static int MaxCascadeResolution { get; set; } = 2048;
[ConVar( "r.shadows.csm.distance", Min = 500, Max = 50000, Help = "Maximum distance from the camera that directional light shadows are rendered." )]
public static float CascadeDistance { get; set; } = 15000;
[ConVar( "r.shadows.debug", ConVarFlags.Cheat, Help = "Show shadow debug overlay with memory allocation and budget info." )]
public static bool DebugEnabled { get; set; } = false;
[ConVar( "r.shadows.csm.enabled", Help = "Enable directional light (CSM) shadows." )]
public static bool CSMEnabled { get; set; } = true;
[ConVar( "r.shadows.local.enabled", Help = "Enable local light (spot/point) shadows." )]
public static bool LocalShadowsEnabled { get; set; } = true;
[ConVar( "r.shadows.depthbias", Min = -256, Max = 0, Help = "Rasterizer constant depth bias applied during shadow map rendering. More negative = stronger bias." )]
public static int ShadowDepthBias { get; set; } = -1;
[ConVar( "r.shadows.slopescale", Min = -16.0f, Max = 0.0f, Help = "Rasterizer slope-scaled depth bias applied during shadow map rendering. More negative = stronger bias on angled surfaces." )]
public static float ShadowSlopeScale { get; set; } = -1.5f;
const uint InvalidShadowIndex = 0xFFFFFFFF;
// Absolute upper limits, no harm in increasing these
const int ProjectedShadowBufferSize = 512;
const int ProjectedCubeShadowBufferSize = 256;
int ShadowsAllocated { get; set; }
/// <summary>
/// Lifetime counters for tracking texture allocation health.
/// Created - Disposed should equal cache + pool + in-flight at any point.
/// If in-flight grows indefinitely, textures are being orphaned.
/// </summary>
internal static long TotalTexturesCreated { get; private set; }
internal static long TotalTexturesReleased { get; private set; }
internal static long TotalTexturesDisposed { get; private set; }
public ShadowMapper()
{
// This ties our buffers to a specific lightbinner.
// They're all the same size at the end of the day, we could pool them normally.
GPUProjectedShadowsBuffer = new( ProjectedShadowBufferSize );
GPUProjectedCubeShadowsBuffer = new( ProjectedCubeShadowBufferSize );
}
ISceneView SceneView { get; set; }
internal void InitForView( ISceneView sceneView )
{
SceneView = sceneView;
// Evict stale shadow maps and clean the texture pool
Update();
// Reset all our lists
GPUProjectedShadows.Clear();
GPUProjectedCubeShadows.Clear();
GPUDirectionalLightData.CascadeCount = 0;
GPUDirectionalLightData.Enabled = false;
ShadowsAllocated = 0;
// Save statistics from last frame, then reset
ProjectedShadowsRenderedLastFrame = ProjectedShadowsRendered;
ProjectedShadowsCulledLastFrame = ProjectedShadowsCulled;
ProjectedShadowsRendered = 0;
ProjectedShadowsCulled = 0;
}
internal void SetShaderAttributes( RenderAttributes attributes )
{
if ( attributes is null )
return;
attributes.Set( "ProjectedShadows", GPUProjectedShadowsBuffer );
attributes.Set( "ProjectedCubeShadows", GPUProjectedCubeShadowsBuffer );
attributes.SetData( "DirectionalLightCB", GPUDirectionalLightData );
attributes.Set( "DirectionalLightDebug", DebugEnabled );
}
/// <summary>
/// Called as we submit display lists, upload our shadow map buffers.
/// </summary>
internal void UploadToGPU()
{
GPUProjectedShadowsBuffer.SetData( GPUProjectedShadows );
GPUProjectedCubeShadowsBuffer.SetData( GPUProjectedCubeShadows );
}
public class LightEntry
{
public float LastFrame;
public Texture StaticCache;
public Texture ShadowMap;
public float ScreenSize;
public int CurrentResolution;
public int DesiredResolution;
public int DebugLightIndex;
public bool IsCube;
public string DebugName;
}
public static ConditionalWeakTable<SceneLight, LightEntry> Cache = new();
public static long MemorySize
{
get
{
long total = 0;
foreach ( var kvp in Cache )
{
if ( kvp.Value.ShadowMap is not null )
total += g_pRenderDevice.ComputeTextureMemorySize( kvp.Value.ShadowMap.native );
}
return total;
}
}
struct PooledTexture
{
public Texture Texture;
public float ReturnedAt;
}
record struct PoolKey( int Resolution, bool IsCube );
static readonly Dictionary<PoolKey, Queue<PooledTexture>> TexturePool = new();
/// <summary>
/// How long a texture sits unused in the cache before being evicted and returned to the pool.
/// </summary>
const float CacheEvictionTime = 2.0f;
/// <summary>
/// How long a texture sits unused in the pool before being disposed.
/// </summary>
const float PoolDisposeTime = 10.0f;
static Texture AcquireTexture( int resolution, bool isCube )
{
var key = new PoolKey( resolution, isCube );
if ( TexturePool.TryGetValue( key, out var queue ) && queue.Count > 0 )
return queue.Dequeue().Texture;
TotalTexturesCreated++;
if ( isCube )
return Texture.CreateCube( resolution, resolution, LocalShadowDepthFormat ).AsRenderTarget().Finish();
return Texture.CreateRenderTarget( $"ShadowPool_{resolution}", LocalShadowDepthFormat, new Vector2( resolution ) );
}
static void ReleaseTexture( Texture texture, int resolution, bool isCube )
{
if ( texture is null )
return;
var key = new PoolKey( resolution, isCube );
if ( !TexturePool.TryGetValue( key, out var queue ) )
{
queue = new Queue<PooledTexture>();
TexturePool[key] = queue;
}
queue.Enqueue( new PooledTexture { Texture = texture, ReturnedAt = RealTime.Now } );
TotalTexturesReleased++;
}
/// <summary>
/// Evict shadow maps from lights that haven't been rendered recently,
/// and dispose pooled textures that have sat idle for too long.
/// </summary>
public static void Update()
{
float now = RealTime.Now;
// Evict stale cache entries
List<SceneLight> toEvict = null;
foreach ( var kvp in Cache )
{
if ( now - kvp.Value.LastFrame < CacheEvictionTime )
continue;
toEvict ??= new();
toEvict.Add( kvp.Key );
}
if ( toEvict is not null )
{
foreach ( var light in toEvict )
{
if ( Cache.TryGetValue( light, out var entry ) )
{
ReleaseTexture( entry.ShadowMap, entry.CurrentResolution, entry.IsCube );
entry.ShadowMap = null;
Cache.Remove( light );
}
}
}
// Dispose pooled textures that have been idle for too long
foreach ( var kvp in TexturePool )
{
var queue = kvp.Value;
while ( queue.Count > 0 && now - queue.Peek().ReturnedAt >= PoolDisposeTime )
{
queue.Dequeue().Texture?.Dispose();
TotalTexturesDisposed++;
}
}
}
/// <summary>
/// Called when a light is removed from the scene. Returns its shadow map to the pool.
/// </summary>
public static void OnLightRemoved( SceneLight light )
{
if ( !Cache.TryGetValue( light, out var entry ) )
return;
ReleaseTexture( entry.ShadowMap, entry.CurrentResolution, entry.IsCube );
entry.ShadowMap = null;
Cache.Remove( light );
}
/// <summary>
/// Scale for rasterizer depth bias. Uses texel-to-depth ratio (tanθ / res),
/// same idea as CSM Width/Far — not world-space texel size.
/// Normalized so a 45° half-angle map at BiasScaleReferenceResolution is 1.0.
/// </summary>
static float ComputeBiasScale( float halfAngleDegrees, float range, int resolution )
{
const int BiasScaleReferenceResolution = 1024;
// (tanθ / resolution) / (tan45° / referenceRes) — range is unused (cancels for depth-unit bias).
// Cap at 1: scaling *up* for low-res/wide cones only detaches shadows (peter-panning).
return Math.Min( 1f, MathF.Tan( halfAngleDegrees * MathF.PI / 180f )
* BiasScaleReferenceResolution
/ Math.Max( resolution, 1 ) );
}
internal static int GetDesiredResolution( float screenSizePercent, int viewportSize )
{
// screenSizePercent is a screen-area fraction from ComputeScreenSize.
// Convert to linear dimension: sqrt(area) gives the fraction of the viewport edge.
float linearSize = MathF.Sqrt( screenSizePercent ) * viewportSize;
// Round down to nearest power of two
int desiredSize = (int)BitOperations.RoundUpToPowerOf2( (uint)Math.Max( linearSize, 1 ) ) >> 1;
return Math.Clamp( desiredSize, 128, MaxResolution );
}
/// <summary>
/// Find a cached shadow map or create a new one for the light and view.
/// Returns an index to the shadow maps structured buffer
/// </summary>
internal unsafe uint FindOrCreateShadowMaps( SceneLight sceneObject, ISceneView view, float flScreenSize )
{
if ( !LocalShadowsEnabled )
return InvalidShadowIndex;
// Unified shadow budget — reject if we've hit the limit
if ( ShadowsAllocated >= MaxShadows )
{
ProjectedShadowsCulled++;
return InvalidShadowIndex;
}
return sceneObject.lightNative.GetLightType() switch
{
3 => FindOrCreateProjectedShadowMap( sceneObject, view, flScreenSize ),
1 => FindOrCreateProjectedCubeShadowMap( sceneObject, view, flScreenSize ),
_ => InvalidShadowIndex
};
}
internal int DoDirectionalLight( SceneLight sceneObject, ISceneView view )
{
GPUDirectionalLightData.Enabled = true;
if ( !CSMEnabled )
return 0;
FindOrCreateDirectionalShadowMaps( sceneObject, view );
return 0;
}
public static void OnSceneObjectTransformCreated() { }
public static void OnSceneObjectTransformRemoved() { }
public static void OnSceneObjectTransformChanged() { }
}