Editing default lit shading model to remove cascaded shadow map stripes/shadow acne

I think I may have a work around for removing the shadow striping artifacts produced by cascaded shadow maps, but I would like some feedback first to see if my idea is valid before attempting to implement it. Cascaded shadow maps create striping artifacts when the surface being lit is nearly parallel to the direction of the light due to issues with computational precision. To get around this, you could just calculate if the normal of the surface is approximately perpendicular to the light direction. In the case that it is (say +/- 5 degrees), you could just override the value read from the shadow map and assume that the triangle should be fully shadowed. Of course this isn’t physically accurate, but it’s preferable to the ugly striping (and even worse, moving of the stripes as the cascaded shadow maps transition from one shadow map to another).

Is there anything wrong with this approach? I need it to work on mobile devices as well, since this is the platform I’m targeting. I think this fix requires modifying the shaders, since UE4 does not give you access to the cascaded shadow maps in the material editor. The shaders in UE4 seem to be very interconnected, so it’s a bit daunting to modify them. If anyone has tips on editing the default lit shading model (specifically the portion dealing with cascaded shadow maps), I would appreciate it!

My original proposal of just calculating the angle between the normal and light direction and setting it to shadowed if it’s nearly perpendicular has a couple issues. First, the shadow will just suddenly pop onto the face when the critical angle is reached, which is pretty jarring. If I smooth out the transition, then any shadows from other objects will not be visible during the transition. An object’s shadow may suddenly disappear while moving over this fake shadow.

Fortunately, I found another solution. I was able to get pretty good results with the mobile shader (I still haven’t found where the cascaded shadow map is for shader model 5). These are my edits to MobileBasePassPixelShader.usf (highlighted in red)

// Cascaded Shadow Map
FPCFSamplerSettings Settings;
Settings.ShadowDepthTexture = MobileDirectionalLight.DirectionalLightShadowTexture;
Settings.ShadowDepthTextureSampler = MobileDirectionalLight.DirectionalLightShadowSampler;
Settings.TransitionScale = MobileDirectionalLight.DirectionalLightShadowTransition;
Settings.ShadowBufferSize = MobileDirectionalLight.DirectionalLightShadowSize;
Settings.bSubsurface = false;
Settings.bTreatMaxDepthUnshadowed = false;
Settings.DensityMulConstant = 0;
Settings.ProjectionDepthBiasParameters = 0;

        float4 ShadowPosition = float4(0,0,0,0);
        for (int i = 0; i < MAX_MOBILE_SHADOWCASCADES; i++)
            if (MaterialParameters.ScreenPosition.w < MobileDirectionalLight.DirectionalLightShadowDistances*)
                ShadowPosition = mul(float4(MaterialParameters.ScreenPosition.xyw, 1), MobileDirectionalLight.DirectionalLightScreenToShadow*);
                break; // position found.

        // Process CSM only when ShadowPosition is valid.
        if (ShadowPosition.z > 0)
            // Clamp pixel depth in light space for shadowing opaque, because areas of the shadow depth buffer that weren't rendered to will have been cleared to 1
            // We want to force the shadow comparison to result in 'unshadowed' in that case, regardless of whether the pixel being shaded is in front or behind that plane
            float LightSpacePixelDepthForOpaque = min(ShadowPosition.z, 0.99999f);
            Settings.SceneDepth = LightSpacePixelDepthForOpaque;

            #if MOBILE_CSM_QUALITY == 0
                half ShadowMap = ManualNoFiltering(ShadowPosition.xy, Settings);
            #elif MOBILE_CSM_QUALITY == 1
                half ShadowMap = Manual1x1PCF(ShadowPosition.xy, Settings);
            #elif MOBILE_CSM_QUALITY == 2
                half ShadowMap = Manual2x2PCF(ShadowPosition.xy, Settings);
                #error Unsupported MOBILE_CSM_QUALITY value.

            #if FADE_CSM
                float Fade = saturate(MaterialParameters.ScreenPosition.w * MobileDirectionalLight.DirectionalLightDistanceFadeMAD.x + MobileDirectionalLight.DirectionalLightDistanceFadeMAD.y);
                // lerp out shadow based on fade params.
                ShadowMap = lerp(ShadowMap, 1.0, Fade * Fade);

                Shadow = ShadowMap;
                Shadow = min(ShadowMap, Shadow);

// Shadow values above 0.25 can potentially be shadow striping artifacts, so assume it shouldn’t be shadowed and set Shadow to 1
if(Shadow > 0.25f)
Shadow = 1.f;

    float NoL = max(0, dot(float3(MaterialParameters.WorldNormal), float3(MobileDirectionalLight.DirectionalLightDirection)));

// Remove potential shadow striping artifacts based on weak shadow values and if the normal is within 16 degrees of perpendicular with the light direction
/if(Shadow > 0.25f && NoL < 0.275f)
Shadow = 1.f;

The first edit will remove any shadows with values greater than 0.25 (the higher the shadow value, the weaker the shadow). Since most striping artifacts only produce weak shadows, this will remove striping artifacts on all models with flat normals and most models with smooth normals. This comes at a cost, though. The edges of the shadows will not have a smooth blur (though this may be preferred if you want crisp shadows and don’t mind slightly jagged shadow boundaries).

The second edit (which is commented out) will only remove shadow values greater than 0.25 if the normal is near perpendicular to the light direction (from my tests, it seems like 16 degrees from perpendicular is about where the striping artifacts begin appearing). This allows you to keep blurred shadow edges on most faces. This works perfectly for models with flat normals, but models with smooth normals will have more striping artifacts than the first approach.

Regardless, I’m pretty happy with the result so far :slight_smile:

These edits will only work on mobile. Just copy them over to MobileBasePassPixelShader.usf (replacing the corresponding sections of the original shader). The shaders will recompile if you restart the engine or type “recompileshaders changed” without the quotes into the console.

You can just smoothstep between no shadow and full shadow within set angle threshold between lightvector and surface normal and taking max between that and calculated shadow in shadow projection shader.
However this approach is very situational, and the issues it brigs may outweight the benefits by far.

Conventional normal offset / slope bias copes with the issue much better.

You could just scale and bias NoL. Leave everything else as is.

float bias = 0.05
Nol = saturate((1.0 + bias) * NoL - bias);

Thanks for the input! Not sure if you guys read to the end of my second post, but I was able to retain the smooth shadow transition when rotating away from the sun and still have external objects casting shadows onto the face. Smoothstepping between no shadow and full shadow doesn’t allow for external objects to cast shadows on that face during the transition. Scaling and biasing NoL messes with the lighting for every surface, and the bias is 16 degrees, which is pretty noticeable.

I’m just putting out my solution in cases anyone runs into this same problem. It’s unavoidable when having dynamic day/night lighting, especially on mobile devices since it uses half float accuracy. As a bonus, I can set my shadow bias to 0.01 and still have no artifacts on flat shaded surfaces.