This seems to work in the URP sample project; haven’t spent too much time testing but basically just drop the script and shader there, and add the ScreenSpaceShadowsFeature and the BlurScreenSpaceShadowsFeature both to the renderer. Mess with the blur radius and clamp values as desired.
The script:
using System;
using System.Reflection;
using UnityEngine;
using UnityEngine.Experimental.Rendering;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UObject = UnityEngine.Object;
using UDebug = UnityEngine.Debug;
namespace burningmime.unity.graphics
{
class BlurScreenSpaceShadowsFeature : ScriptableRendererFeature
{
[SerializeField] private float _blurRadius = 2;
[SerializeField] private float _maxDepthDiff = .00075f;
[SerializeField] private float _maxNormalDiff = .1f;
private ScriptableRenderPass _pass;
public override void Create()
{
// defer creating the pass to the AddRenderPasses() to support domain-safe editor reloading, changing
// render modes, etc, etc.
}
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData rd)
{
if(_pass == null)
{
_pass ??= new BlurScreenSpaceShadowsPass(this);
RenderPassEvent baseEvent = Utils.isDeferredUrp(renderer) ? RenderPassEvent.AfterRenderingGbuffer : RenderPassEvent.AfterRenderingPrePasses;
_pass.renderPassEvent = (RenderPassEvent) ((int) baseEvent + 1);
}
renderer.EnqueuePass(_pass);
}
private class BlurScreenSpaceShadowsPass : ScriptableRenderPass
{
private static readonly int ID_SHADOWS_TEXTURE = Shader.PropertyToID("_ScreenSpaceShadowmapTexture");
private static readonly int ID_INTERMEDIATE_TEXTURE = Shader.PropertyToID("_BlurScreenSpaceShadows_Temp");
private static readonly int ID_BLUR_RADIUS = Shader.PropertyToID("_BlurScreenSpaceShadows_blurRadius");
private static readonly int ID_MAX_DEPTH_DIFF = Shader.PropertyToID("_BlurScreenSpaceShadows_maxDepthDiff");
private static readonly int ID_MAX_NORMAL_DIFF = Shader.PropertyToID("_BlurScreenSpaceShadows_maxNormalDiff");
private readonly BlurScreenSpaceShadowsFeature _owner;
private readonly ProfilingSampler _profilingSampler = new(nameof(BlurScreenSpaceShadowsPass));
private readonly Material _material = Utils.newMaterial("Hidden/burningmime/BlurScreenSpaceShadows");
public BlurScreenSpaceShadowsPass(BlurScreenSpaceShadowsFeature owner)
=> _owner = owner;
public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cd)
{
ConfigureInput(ScriptableRenderPassInput.Depth | ScriptableRenderPassInput.Normal);
ConfigureClear(ClearFlag.None, Color.clear);
}
public override void Execute(ScriptableRenderContext ctx, ref RenderingData rd)
{
CommandBuffer cmd = CommandBufferPool.Get();
using(new ProfilingScope(cmd, _profilingSampler))
executeMain(cmd, ref rd);
ctx.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}
private void executeMain(CommandBuffer cmd, ref RenderingData rd)
{
cmd.GetTemporaryRT(ID_INTERMEDIATE_TEXTURE,
rd.cameraData.cameraTargetDescriptor.width, rd.cameraData.cameraTargetDescriptor.height, 0,
FilterMode.Bilinear, GraphicsFormat.R8_UNorm);
cmd.SetGlobalFloat(ID_BLUR_RADIUS, _owner._blurRadius);
cmd.SetGlobalFloat(ID_MAX_DEPTH_DIFF, _owner._maxDepthDiff);
cmd.SetGlobalFloat(ID_MAX_NORMAL_DIFF, _owner._maxNormalDiff);
Blit(cmd, ID_SHADOWS_TEXTURE, ID_INTERMEDIATE_TEXTURE, _material, 0);
Blit(cmd, ID_INTERMEDIATE_TEXTURE, ID_SHADOWS_TEXTURE, _material, 1);
cmd.ReleaseTemporaryRT(ID_INTERMEDIATE_TEXTURE);
}
}
#region I copied a bunch of this stuff from my internal code 'cause I'm too lazy to rewrite it all, but all the improtant stuff is above anyways
private static class Utils
{
private static readonly Func<UniversalRenderer, RenderingMode> _getRenderingMode =
createDelegate<Func<UniversalRenderer, RenderingMode>>(typeof(UniversalRenderer), "renderingMode", CreateDelegateSource.GET_PROPERTY);
public static bool isDeferredUrp(ScriptableRenderer renderer) => renderer is UniversalRenderer urp && _getRenderingMode(urp) == RenderingMode.Deferred;
public static Material newMaterial(string shaderName) => new(findShader(shaderName));
private static Shader findShader(string shaderName) { Shader shader = Shader.Find(shaderName); if(!shader) throw new Exception("Could not find shader " + shaderName); return shader; }
private enum CreateDelegateSource { METHOD, GET_PROPERTY, SET_PROPERTY }
private static TDelegate createDelegate<TDelegate>(Type type, string name, CreateDelegateSource source = CreateDelegateSource.METHOD) where TDelegate : Delegate
{
const BindingFlags BINDING_FLAGS = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.FlattenHierarchy | BindingFlags.Static | BindingFlags.Instance;
MethodInfo method;
if(source == CreateDelegateSource.METHOD)
method = type.GetMethod(name, BINDING_FLAGS) ?? throw new Exception($"Could not find method {type.FullName}#{name}");
else {
PropertyInfo property = type.GetProperty(name, BINDING_FLAGS) ?? throw new Exception($"Could not find property {type.FullName}#{name}");
method = source == CreateDelegateSource.GET_PROPERTY ? property.GetGetMethod(true) : property.GetSetMethod(true);
if(method == null)
throw new Exception($"Did not find the required get/set method kind on property {type.FullName}#{name}"); }
Delegate dg = Delegate.CreateDelegate(typeof(TDelegate), method);
if(dg == null)
throw new Exception($"Error creating delegate of {typeof(TDelegate)} for {type.FullName}#{name}");
return (TDelegate) dg;
}
}
#endregion
}
}
The shader:
Shader "Hidden/burningmime/BlurScreenSpaceShadows"
{
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareNormalsTexture.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
TEXTURE2D(_MainTex);
SAMPLER(sampler_linear_clamp);
float4 _MainTex_TexelSize;
float _BlurScreenSpaceShadows_blurRadius;
float _BlurScreenSpaceShadows_maxDepthDiff;
float _BlurScreenSpaceShadows_maxNormalDiff;
// vertex shader
struct a2v { float3 pos : POSITION; float2 uv : TEXCOORD0; };
struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; };
v2f fullscreen_VS(a2v IN)
{
v2f OUT;
OUT.pos = TransformObjectToHClip(IN.pos);
OUT.uv = IN.uv;
if(_ProjectionParams.x < 0)
OUT.uv.y = 1 - OUT.uv.y;
return OUT;
}
// offsetting coordinates
float2 blurCoords(float offset, bool isX, float2 centerUV)
{
return isX
? float2(saturate(centerUV.x + offset * _MainTex_TexelSize.x * _BlurScreenSpaceShadows_blurRadius), centerUV.y)
: float2(centerUV.x, saturate(centerUV.y + offset * _MainTex_TexelSize.y * _BlurScreenSpaceShadows_blurRadius));
}
// basic idea is to blur but use the center value if the depth or normals are too different. ideally, we would be using
// the closest valid value (eg clamping), and we could also scale the max difference by distance. not doing either of
// those things for now to keep the shader simple, but ultimately might need to do one or both to cut down on artifacts
float blurShadow(bool isX, float2 uvc)
{
// these need to be in the function itself; if they were consts outside then they would be part of the CBUFFER and
// the shader code would be a ton more complex
int nSamples = 9;
float blurOffsets[9] = { 0, -4, -3, -2, -1, 1, 2, 3, 4 };
float blurWeights[9] = { 0.18, 0.05, 0.09, 0.12, 0.15, 0.15, 0.12, 0.09, 0.05 };
float centerValue = SAMPLE_TEXTURE2D(_MainTex, sampler_linear_clamp, uvc).r;
float centerDepth = SampleSceneDepth(uvc);
float3 centerNormal = SampleSceneNormals(uvc);
float sum = centerValue * blurWeights[0];
UNITY_UNROLL for(int i = 1; i < nSamples; ++i)
{
float2 uv = blurCoords(blurOffsets[i], isX, uvc);
float sampleValue = SAMPLE_TEXTURE2D(_MainTex, sampler_linear_clamp, uv).r;
float sampleDepth = SampleSceneDepth(uv);
float3 sampleNormal = SampleSceneNormals(uv);
float value =
abs(sampleDepth - centerDepth) > _BlurScreenSpaceShadows_maxDepthDiff
|| dot(sampleNormal, centerNormal) < 1 - _BlurScreenSpaceShadows_maxNormalDiff
? centerValue : sampleValue;
sum += value * blurWeights[i];
}
return sum;
}
// actual shader functions are all just thin wrappers
float4 blurShadowX_PS(v2f IN) : SV_Target { return float4(blurShadow(true, IN.uv), 0, 0, 1); }
float4 blurShadowY_PS(v2f IN) : SV_Target { return float4(blurShadow(false, IN.uv), 0, 0, 1); }
ENDHLSL
Properties { [HideInInspector] _MainTex("_MainTex", 2D) = "white" { } }
SubShader
{
Tags { "RenderType" = "Transparent" "RenderPipeline" = "UniversalPipeline" }
Pass
{
Tags { "LightMode" = "SRPDefaultUnlit" }
ZTest Off ZWrite Off Cull Off
HLSLPROGRAM
#pragma vertex fullscreen_VS
#pragma fragment blurShadowX_PS
ENDHLSL
}
Pass
{
Tags { "LightMode" = "SRPDefaultUnlit" }
ZTest Off ZWrite Off Cull Off
HLSLPROGRAM
#pragma vertex fullscreen_VS
#pragma fragment blurShadowY_PS
ENDHLSL
}
}
}
The better way would be to determine the max normal diff, max depth diff, and possibly even blur radius based on the depth, projection params, and some screen space derivatives. This would make it adapt to the scene. A picture of mountains would need different numbers than a picture of flowers. A picture taken from the side of an object would need different numbers than a picture taken from an angle on top of the object.
If you’re interested in this, you could adapt some existing techniques for shadow biasing eg C0DE517E: Shadowmap bias notes – this is a different problem but a similar technique could work.
You could also just get rid of the maxDepthDiff and maxNormalDiff stuff entirely. It might look OK in your project.