Hair on characters at far distance is overly bright when using a low-angle directional light.

Hello!

I have a scene with a low-angle, high intensity, shadow casting directional light (morning daytime). Characters with hair look fine up close, but as the camera moves further away, the hair becomes overly bright. It’s most obvious when the direction from the camera to the hair is almost exactly opposite the direction of the directional light. It does not depend on the camera rotation, only the position.

While we’re using 5.5, I’ve reproduced this issue in the city sample project in 5.7.

The issue shows up when the hair cards are culled from casting shadows. Changing r.Shadow.RadiusThreshold does affect how close the issue appears (a lower value makes the issue start to appear further out). While that helps mitigate the issue, because of how bright the hair becomes (especially for lighter colored hair), it’s still eye-catching even at a distance.

Are there any changes that can actually resolve this issue? I’ve considered modifying the shadow culling cpp code to try and prevent hair from ever being culled, but figured I should check here first.

============

The following screenshots show the issue in the city sample. They are taken in editor, though the issue does also appear when actually playing. The last screenshot raises the radius threshold cvar to show that it does affect how close the issue appears, in this case causing it to show closer than it does with the default value.

Hair from behind:

Hair from front (close):

Hair from front (far):

Hair from front (close, raised r.Shadow.RadiusThreshold to 0.1):

Steps to Reproduce

  • Open the City Sample (confirmed in 5.7)
  • Go to the Small_City_LVL map
    • load a portion of the level from world partition (may not be needed as this doesn’t relate to WP)
  • Place a BP_CrowdCharacter instance
    • In its Additional_Head component, set the skeletal mesh asset to f_001_nrw_FaceMesh (any mesh should work)
    • In its Groom_Hair component, set the groom asset to Hair_M_BobCurly (any groom should work, this one is just long so it more clearly exhibits the issue)
  • Select the DirectionalLight_WP
    • Set its rotation to a low angle (e.x. 0, -16, 0)
  • Move the crowd character head to a location that isn’t shadowed by another object
    • The location in the screenshots of description is (X=-63139.204416,Y=-29519.470911,Z=218.413450)
  • Observe that when viewed from far away at certain angles (seems like when the direction from the camera to the hair is close to directly opposite to direction from directional light to hair), the hair becomes extremely bright
    • Changing r.Shadow.RadiusThreshold to a higher value (like 0.1) makes the issues manifest when the camera closer than it would by default (value of 0.01)

I can provide a repro zip as needed, but it would just be a largely unmodified city sample (aside from the one actor placement and the rotated directional light).

Hi,

This is kind of a known issue.

As you saw, after a certain distance, the head mesh and the cards meshes get culled from the VSM rendering, as their size (in shadowmap space) become smaller than the defined threshold (r.Shadow.RadiusThreshold). The smaller that value is, the further away the issue appears, but at the cost of rendering very small objects into the shadowmap.

The only workaround I can think of would be to force the “hair no shadow-casting light” path. This path is intended to use screen space occlusion for hair when a light does not have its shadow casting enabling. This avoids the glowing issue you mentioned. You could force that path all the time so that it wouldn’t leak in distance.

You can try that path by modifing Engine\Shaders\Private\DeferredLightingCommon.ush. In that file, there is a function called void ApplyContactShadowWithShadowTerms(). Replace it with the following version:

void ApplyContactShadowWithShadowTerms(
	float SceneDepth, 
	uint ShadingModelID, 
	float ContactShadowOpacity, 
	FDeferredLightData LightData, 
	float3 TranslatedWorldPosition, 
	half3 L, 
	float Dither, 
	inout FShadowTerms OutShadow)
{
#if SUPPORT_CONTACT_SHADOWS
 
	float ContactShadowLength = 0.0f;
	const float ContactShadowLengthScreenScale = GetScreenRayLengthMultiplierForProjectionType(SceneDepth).y;
 
	FLATTEN
	if (LightData.ShadowedBits > 1 && LightData.ContactShadowLength > 0)
	{
		ContactShadowLength = LightData.ContactShadowLength * (LightData.ContactShadowLengthInWS ? 1.0f : ContactShadowLengthScreenScale);
	}
 
	if ((View.GeneralPurposeTweak > 0 || LightData.ShadowedBits < 2) && (ShadingModelID == SHADINGMODELID_HAIR))
	{
		ContactShadowLength = 0.2 * ContactShadowLengthScreenScale;
	}
	// World space distance to cover eyelids and eyelashes but not beyond
	if (ShadingModelID == SHADINGMODELID_EYE)
	{
		ContactShadowLength = 0.5;
		
	}
 
#if MATERIAL_CONTACT_SHADOWS
	ContactShadowLength = 0.2 * ContactShadowLengthScreenScale;
#endif
 
	BRANCH
	if (ContactShadowLength > 0.0)
	{
		bool bHitCastContactShadow = false;
		bool bHairNoShadowLight = ShadingModelID == SHADINGMODELID_HAIR && View.GeneralPurposeTweak > 0; //!LightData.ShadowedBits;
		float HitDistance = ShadowRayCast( TranslatedWorldPosition, L, ContactShadowLength, 8, Dither, bHairNoShadowLight, bHitCastContactShadow );
				
		if ( HitDistance > 0.0 )
		{
			float ContactShadowOcclusion = bHitCastContactShadow ? LightData.ContactShadowCastingIntensity : LightData.ContactShadowNonCastingIntensity;
 
			// Exponential attenuation is not applied on hair/eye/SSS-profile here, as the hit distance (shading-point to blocker) is different from the estimated 
			// thickness (closest-point-from-light to shading-point), and this creates light leaks. Instead we consider first hit as a blocker (old behavior)
			BRANCH
			if (ContactShadowOcclusion > 0.0 && 
				IsSubsurfaceModel(ShadingModelID) &&
				ShadingModelID != SHADINGMODELID_HAIR &&
				ShadingModelID != SHADINGMODELID_EYE &&
				ShadingModelID != SHADINGMODELID_SUBSURFACE_PROFILE)
			{
				// Reduce the intensity of the shadow similar to the subsurface approximation used by the shadow maps path
				// Note that this is imperfect as we don't really have the "nearest occluder to the light", but this should at least
				// ensure that we don't darken-out the subsurface term with the contact shadows
				float Density = SubsurfaceDensityFromOpacity(ContactShadowOpacity);
				ContactShadowOcclusion *= 1.0 - saturate( exp( -Density * HitDistance ) );
			}
 
			float ContactShadow = 1.0 - ContactShadowOcclusion;
 
			OutShadow.SurfaceShadow *= ContactShadow;
			OutShadow.TransmissionShadow *= ContactShadow;
		}
	}
 
	OutShadow.HairTransmittance = LightData.HairTransmittance;
	OutShadow.HairTransmittance.OpaqueVisibility = OutShadow.SurfaceShadow;
 
#endif // SUPPORT_CONTACT_SHADOWS
}

With that version you can use CVar r.GeneralPurposeTweak 0/1 to see how it changes the behavior.

I hope this helps.

/Charles.

Apologies for the late reply!

Thank you for confirming, and yes the workaround you gave does help! Talking with my team now to determine whether we want the trade-off in response quality for the stability the workaround provides, but either way I think it’s enough to go on