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
This commit is contained in:
Sam Pavlovic
2026-07-14 05:45:03 +02:00
committed by GitHub
parent bf6b842faa
commit 2388933716
4 changed files with 43 additions and 15 deletions

View File

@@ -153,7 +153,8 @@ public class SceneMapLoader : MapLoader
Attenuation0 = kv.GetValue( "attenuation0", 0.0f ),
Attenuation1 = kv.GetValue( "attenuation1", 0.0f ),
Attenuation2 = kv.GetValue( "attenuation2", 1.0f ),
CastShadows = kv.GetValue<int>( "castshadows" ) == 1,
// Default 1 matches Hammer ShadowType.Yes. 0 = none, 1 = realtime (+ baked), 2 = baked-only.
CastShadows = kv.GetValue( "castshadows", 1 ) == 1,
LightCookie = kv.GetResource<Texture>( "lightcookie" ),
BakeLightIndex = kv.GetValue( "bakelightindex", -1 ),
BakeLightIndexScale = kv.GetValue( "bakelightindexscale", 1.0f ),
@@ -173,6 +174,7 @@ public class SceneMapLoader : MapLoader
public readonly Light.LegacyLightData ToLegacyData() => new()
{
DirectLight = DirectLight,
CastShadows = CastShadows,
BakeLightIndex = BakeLightIndex,
BakeLightIndexScale = BakeLightIndexScale,
BakedLightIndexing = BakedLightIndexing,

View File

@@ -23,6 +23,7 @@ public abstract class Light : Component, IColorProvider, ExecuteInEditor, ITinta
internal struct LegacyLightData
{
public int DirectLight;
public bool CastShadows;
public int BakeLightIndex;
public float BakeLightIndexScale;
public bool BakedLightIndexing;
@@ -31,21 +32,30 @@ public abstract class Light : Component, IColorProvider, ExecuteInEditor, ITinta
public bool RenderSpecular;
public int FogLighting;
// Only set by light types whose components don't expose these (falloff, point cookie).
// Only set by light types whose components don't expose these (falloff, constant attn, point cookie).
public float? ConstantAttenuation;
public float? LinearAttenuation;
public float? QuadraticAttenuation;
public float? FallOff;
public Texture Cookie;
public readonly void ApplyTo( CSceneLightObject light )
{
// Lightbinner only allocates realtime shadow maps for BAKED lights when MIXED_SHADOWS
// is set (Stationary, or Baked with castshadows enabled). Without it, indexed/baked
// lights light the scene but never cast shadows.
switch ( DirectLight )
{
case 3: // HAMMER_DIRECT_LIGHT_STATIONARY
case 3: // HAMMER_DIRECT_LIGHT_STATIONARY — always mixed
light.SetLightFlags( light.GetLightFlags() | 16 ); // LIGHTTYPE_FLAGS_MIXED_SHADOWS
light.SetLightFlags( light.GetLightFlags() | 32 ); // LIGHTTYPE_FLAGS_BAKED
break;
case 1: // HAMMER_DIRECT_LIGHT_BAKED
light.SetLightFlags( light.GetLightFlags() | 32 ); // LIGHTTYPE_FLAGS_BAKED
// Hammer default is castshadows=Yes on baked lights; treat that as mixed so
// indexed map lights keep realtime dynamic shadows (static comes from lightmaps).
if ( CastShadows )
light.SetLightFlags( light.GetLightFlags() | 16 ); // LIGHTTYPE_FLAGS_MIXED_SHADOWS
break;
}
@@ -57,8 +67,10 @@ public abstract class Light : Component, IColorProvider, ExecuteInEditor, ITinta
light.SetRenderSpecular( RenderSpecular );
light.SetFogLightingMode( FogLighting );
if ( ConstantAttenuation is { } constant ) light.SetConstantAttn( constant );
if ( LinearAttenuation is { } linear ) light.SetLinearAttn( linear );
if ( QuadraticAttenuation is { } quadratic ) light.SetQuadraticAttn( quadratic );
if ( FallOff is { } fallOff ) light.SetFallOff( fallOff );
if ( Cookie is not null ) light.SetLightCookie( Cookie.native );
}
}

View File

@@ -678,8 +678,8 @@ file class MapComponentMapLoader : SceneMapLoader
//
void CreateLightComponent( GameObject go, ObjectEntry kv, LightType type )
{
// Never network these - LegacyData is internal (not serialized), so a snapshot copy
// would arrive half-configured, and every client builds an identical light from the vpk anyway.
// Never network these LegacyData is internal (not serialized), so a snapshot copy would
// arrive half-configured. Every client builds an identical light from the map VPK anyway.
go.NetworkMode = NetworkMode.Never;
var data = LightData.Parse( kv, type );
@@ -698,8 +698,16 @@ file class MapComponentMapLoader : SceneMapLoader
spot.ConeOuter = data.OuterConeAngle;
spot.Cookie = data.LightCookie;
// Hammer lights have attenuation and cone mask from the lightmap itself
// We can take advantage of that and tighten the cone for sharper shadows
spot.ConeInner = MathF.Min( data.InnerConeAngle, 80.0f );
spot.ConeOuter = MathF.Min( data.OuterConeAngle, 80.0f );
// Native quadratic is the Hammer coefficient; SceneLight.QuadraticAttenuation scales by 10000.
legacy.ConstantAttenuation = data.Attenuation0;
legacy.LinearAttenuation = data.Attenuation1;
legacy.QuadraticAttenuation = data.Attenuation2;
legacy.FallOff = data.FallOff;
light = spot;
}
@@ -708,6 +716,7 @@ file class MapComponentMapLoader : SceneMapLoader
var point = go.Components.Create<PointLight>();
point.Radius = data.Range;
legacy.ConstantAttenuation = data.Attenuation0;
legacy.LinearAttenuation = data.Attenuation1;
legacy.QuadraticAttenuation = data.Attenuation2;
legacy.Cookie = data.LightCookie; // PointLight doesn't expose a cookie property
@@ -719,8 +728,13 @@ file class MapComponentMapLoader : SceneMapLoader
light = go.Components.Create<DirectionalLight>();
}
// Hammer lights have no concept of "hardness" — let's make them reasonably sharp by default.
light.ShadowHardness = 0.5f;
light.LightColor = data.FinalColor;
light.Shadows = data.CastShadows;
// Fog mode / render diffuse-specular / attenuation ride in LegacyData — applied when the
// scene light is created (OnEnabled). Component FogMode can't express Hammer's "Baked" fog.
light.LegacyData = legacy;
}

View File

@@ -251,20 +251,20 @@ internal partial class ShadowMapper
}
/// <summary>
/// Computes a per-light bias scale factor based on the shadow frustum's texel size.
/// Wider cones and larger ranges produce bigger shadow map texels in world space,
/// requiring proportionally more bias to prevent acne. Matches Unity URP's approach
/// of scaling bias by <c>frustumSize / resolution</c>.
/// 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 )
{
float frustumSize = MathF.Tan( halfAngleDegrees * MathF.PI / 180f ) * range;
float texelSize = frustumSize / resolution;
const int BiasScaleReferenceResolution = 1024;
// Normalize against a reference texel size so that a typical mid-range spotlight
// (e.g. 45° half-angle, 200 range, 1024 res) gets a scale of ~1.0.
const float ReferenceTexelSize = 0.2f;
return MathF.Max( 1f, texelSize / ReferenceTexelSize );
// (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 )