This is the fourth part of a tutorial series about creating a custom scriptable render pipeline. It adds support for cascaded shadow maps.

This tutorial is made with Unity 2019.2.14f1.

Rendering Shadows

When drawing something the surface and light information is enough to calculating lighting. But there could be something in between both that blocks the light, casting a shadow on the surface that we're drawing. To make shadows possible we have to somehow make the shader aware of the shadow-casting objects. There are multiple techniques for this. The most common approach is to generate a shadow map that stores how far light can travel away from its source before hitting a surface. Anything further away in the same direction cannot be lit by that same light. Unity's RPs use this approach and so will we.

Shadow Settings Before we get to rendering shadows we first have to make some decisions about quality, specifically up to how far away we will render shadows and how big our shadow map will be. While we could render shadows as far as the camera can see, that would require a lot of drawing and a very large map to cover the area adequately, which is almost never practical. So we'll introduce a maximum distance for shadows, with a minimum of zero and set to 100 units by default. Create a new serializable ShadowSettings class to contain this option. This class is purely a container for configuration options, so we'll give it a public maxDistance field. using UnityEngine; [System.Serializable] public class ShadowSettings { [Min(0f)] public float maxDistance = 100f; } For the map size we'll introduce a TextureSize enum type nested inside ShadowSettings . Use it to defined the allowed texture sizes, all being powers of two in the 256—8192 range. public enum TextureSize { _256 = 256, _512 = 512, _1024 = 1024, _2048 = 2048, _4096 = 4096, _8192 = 8192 } Then add a size field for the shadow map, with 1024 as its default. We'll use a single texture to contain multiple shadow maps, so name it atlasSize . As we only support directional lights for now we also exclusively works with directional shadow maps at this points. But we'll support other light types in the future, which will get their own shadows settings. So put atlasSize inside an inner Directional struct. That way we automatically get an hierarchical configuration in the inspector. [System.Serializable] public struct Directional { public TextureSize atlasSize; } public Directional directional = new Directional { atlasSize = TextureSize._1024 }; Add a field for the shadow settings to CustomRenderPipelineAsset . [SerializeField] ShadowSettings shadows = default; Shadow settings. Pass these settings to the CustomRenderPipeline instance when it gets constructed. protected override RenderPipeline CreatePipeline () { return new CustomRenderPipeline( useDynamicBatching, useGPUInstancing, useSRPBatcher , shadows ); } And make it keep track of them. ShadowSettings shadowSettings; public CustomRenderPipeline ( bool useDynamicBatching, bool useGPUInstancing, bool useSRPBatcher , ShadowSettings shadowSettings ) { this.shadowSettings = shadowSettings; … }

Passing Along Settings From now on we'll pass these settings to the camera renderer when we invoke its Render method. That way it would be easy to add support for changing the shadow settings at runtime, but we won't deal with that in this tutorial. protected override void Render ( ScriptableRenderContext context, Camera[] cameras ) { foreach (Camera camera in cameras) { renderer.Render( context, camera, useDynamicBatching, useGPUInstancing , shadowSettings ); } } CameraRenderer.Render then passes it to Lighting.Setup and also to its own Cull method. public void Render ( ScriptableRenderContext context, Camera camera, bool useDynamicBatching, bool useGPUInstancing , ShadowSettings shadowSettings ) { … if (!Cull( shadowSettings.maxDistance )) { return; } Setup(); lighting.Setup(context, cullingResults , shadowSettings ); … } We need the settings in Cull because the shadow distance is set via the culling parameters. bool Cull ( float maxShadowDistance ) { if (camera.TryGetCullingParameters(out ScriptableCullingParameters p)) { p.shadowDistance = maxShadowDistance; cullingResults = context.Cull(ref p); return true; } return false; } It doesn't make sense to render shadows that are further away than the camera can see, so take the minimum of the max shadow distance and the camera's far clip plane. p.shadowDistance = Mathf.Min( maxShadowDistance , camera.farClipPlane) ; To make the code compile we also have to add a parameter for the shadows settings to Lighting.Setup , but we won't do anything with them just yet. public void Setup ( ScriptableRenderContext context, CullingResults cullingResults , ShadowSettings shadowSettings ) { … }

Shadows Class Although shadows are logically a part of lighting they're rather complex, so let's create a new Shadows class dedicated to them. It starts as a stripped-down stub copy of Lighting , with its own buffer, fields for the context, culling results, and settings, a Setup method to initialize the fields, and an ExecuteBuffer method. using UnityEngine; using UnityEngine.Rendering; public class Shadows { const string bufferName = "Shadows"; CommandBuffer buffer = new CommandBuffer { name = bufferName }; ScriptableRenderContext context; CullingResults cullingResults; ShadowSettings settings; public void Setup ( ScriptableRenderContext context, CullingResults cullingResults, ShadowSettings settings ) { this.context = context; this.cullingResults = cullingResults; this.settings = settings; } void ExecuteBuffer () { context.ExecuteCommandBuffer(buffer); buffer.Clear(); } } Then all Lighting needs to do is keep track of a Shadows instance and invoke its Setup method before SetupLights in its own Setup method. Shadows shadows = new Shadows(); public void Setup (…) { this.cullingResults = cullingResults; buffer.BeginSample(bufferName); shadows.Setup(context, cullingResults, shadowSettings); SetupLights(); … }

Lights with Shadows As rendering shadows requires extra work it can slow down the frame rate, so we'll limit how many shadowed directional lights there can be, independent of how many directional lights are supported. Add a constant for that to Shadows , initially set to just one. const int maxShadowedDirectionalLightCount = 1; We don't know which visible light will get shadows, so we have to keep track of that. Besides that we'll also keep track of some more data per shadowed light later, so let's define an inner ShadowedDirectionalLight struct that only contains the index for now and keep track of an array of those. struct ShadowedDirectionalLight { public int visibleLightIndex; } ShadowedDirectionalLight[] ShadowedDirectionalLights = new ShadowedDirectionalLight[maxShadowedDirectionalLightCount]; To figure out which light gets shadows we'll add a public ReserveDirectionalShadows method with a light and visible light index parameters. It's job is to reserve space in the shadow atlas for the light's shadow map and store the information needed to render them. public void ReserveDirectionalShadows (Light light, int visibleLightIndex) {} As the amount of shadowed lights are limited we have to keep track of how many have already been reserved. Reset the count to zero in Setup . Then check whether we haven't reached the max yet in ReserveDirectionalShadows . If there's space left then store the light's visible index and increment the count. int ShadowedDirectionalLightCount; … public void Setup (…) { … ShadowedDirectionalLightCount = 0; } public void ReserveDirectionalShadows (Light light, int visibleLightIndex) { if (ShadowedDirectionalLightCount < maxShadowedDirectionalLightCount) { ShadowedDirectionalLights[ShadowedDirectionalLightCount++] = new ShadowedDirectionalLight { visibleLightIndex = visibleLightIndex }; } } But shadows should only be reserved for lights that have any. If a light's shadow mode is set to none or its shadows strength is zero then it has no shadows and should be ignored. if ( ShadowedDirectionalLightCount < maxShadowedDirectionalLightCount && light.shadows != LightShadows.None && light.shadowStrength > 0f ) { … } Besides that, it's possible that a visible light ends up not affecting any objects that cast shadows, either because they're configured not to or because the light only affects objects beyond the max shadow distance. We can check this by invoking GetShadowCasterBounds on the culling results for a visible light index. It has a second output parameter for the bounds—which we don't need—and returns whether the bounds are valid. If not the there are no shadows to render for this light and it should be ignored. if ( ShadowedDirectionalLightCount < maxShadowedDirectionalLightCount && light.shadows != LightShadows.None && light.shadowStrength > 0f && cullingResults.GetShadowCasterBounds(visibleLightIndex, out Bounds b) ) { … } Now we can reserve shadows in Lighting.SetupDirectionalLight . void SetupDirectionalLight (int index, ref VisibleLight visibleLight) { dirLightColors[index] = visibleLight.finalColor; dirLightDirections[index] = -visibleLight.localToWorldMatrix.GetColumn(2); shadows.ReserveDirectionalShadows(visibleLight.light, index); }

Creating the Shadow Atlas After reserving shadows we need to render them. We do that after SetupLights finishes in Lighting.Render , by invoking a new Shadows.Render method. shadows.Setup(context, cullingResults, shadowSettings); SetupLights(); shadows.Render(); The Shadows.Render method will delegate rendering of directional shadows to another RenderDirectionalShadows method, but only if there are any shadowed lights. public void Render () { if (ShadowedDirectionalLightCount > 0) { RenderDirectionalShadows(); } } void RenderDirectionalShadows () {} Creating the shadow map is done by drawing shadow-casting objects to a texture. We'll use _DirectionalShadowAtlas to refer to the directional shadow atlas. Retrieve the atlas size as an integer from the settings and then invoke GetTemporaryRT on the command buffer, with the texture identifier as an argument, plus the size for both its width and height in pixels. static int dirShadowAtlasId = Shader.PropertyToID("_DirectionalShadowAtlas"); … void RenderDirectionalShadows () { int atlasSize = (int)settings.directional.atlasSize; buffer.GetTemporaryRT(dirShadowAtlasId, atlasSize, atlasSize); } That claims a square render texture, but it's a normal ARGB texture by default. We need a shadow map, which we specify by adding another three arguments to the invocation. First is the amount of bits for the depth buffer. We want this to be as high as possible, so let's use 32. Second is the filter mode, for which we use the default bilinear filtering. Third is the render texture type, which has to be RenderTextureFormat.Shadowmap . This gives us a texture suitable for rendering shadow maps, though the exact format depends on the target platform. buffer.GetTemporaryRT( dirShadowAtlasId, atlasSize, atlasSize , 32, FilterMode.Bilinear, RenderTextureFormat.Shadowmap ); What kind of texture format do we get? It's typically a 24 or 32 bits integer or floating-point texture. When we get a temporary render texture we should also release it when we're done with it. We have to keep hold of it until we're finished rendering with the camera, after which we can release it by invoking ReleaseTemporaryRT with the texture identifier of the buffer and then execute it. We'll do that in a new public Cleanup method, if we had shadowed directional lights. public void Cleanup () { if (ShadowedDirectionalLightCount > 0) { buffer.ReleaseTemporaryRT(dirShadowAtlasId); ExecuteBuffer(); } } Give Lighting a public Cleanup method as well, which forwards the invocation to Shadows . public void Cleanup () { shadows.Cleanup(); } Then CameraRenderer can request cleanup directly before submitting. public void Render (…) { … lighting.Cleanup(); Submit(); } After requesting the render texture Shadows.Render must also instruct the GPU to render to this texture instead of the camera's target. that's done by invoking SetRenderTarget on the buffer, identifying a render texture and how its data should be loaded and stored. We don't care about its initial state as we'll immediately clear it, so we'll use RenderBufferLoadAction.DontCare . And the purpose of the texture is to contain the shadow data, so we'll need to use RenderBufferStoreAction.Store as the third argument. buffer.GetTemporaryRT(…); buffer.SetRenderTarget( dirShadowAtlasId, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store ); Once that's done we can use ClearRenderTarget the same way we clear the camera target, in this case only caring about the depth buffer. Finish by executing the buffer. If you have at least one shadowed directional light active then you'll see the clear action of the shadow atlas show up in the frame debugger. buffer.SetRenderTarget( dirShadowAtlasId, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store ); buffer.ClearRenderTarget(true, false, Color.clear); ExecuteBuffer(); Clearing two render targets.

Shadows First As we're setting up the regular camera before the shadow atlas we end up switching to the shadow atlas before rendering the regular geometry, which is not what we want. We should render the shadows before invoking CameraRenderer.Setup in CameraRenderer.Render so regular rendering will not be affected. //Setup(); lighting.Setup(context, cullingResults, shadowSettings); Setup(); DrawVisibleGeometry(useDynamicBatching, useGPUInstancing); Shadows first. We can keep the shadows entry nested inside the camera's in the frame debugger by beginning a sample before setting up lighting and ending the sample immediately after it, before clearing the camera's target. buffer.BeginSample(SampleName); ExecuteBuffer(); lighting.Setup(context, cullingResults, shadowSettings); buffer.EndSample(SampleName); Setup(); Nested shadows.

Rendering To render shadows for a single light we'll add a variant RenderDirectionalShadows method to Shadow , with two parameters: first the shadowed light index and second the size of its tile in the atlas. Then invoke this method for all shadowed lights in the other RenderDirectionalShadows method, wrapped by BeginSample and EndSample invocations. As we're currently supporting only a single shadowed light its tile size is equal to the atlas size. void RenderDirectionalShadows () { … buffer.ClearRenderTarget(true, false, Color.clear); buffer.BeginSample(bufferName); ExecuteBuffer(); for (int i = 0; i < ShadowedDirectionalLightCount; i++) { RenderDirectionalShadows(i, atlasSize); } buffer.EndSample(bufferName); ExecuteBuffer(); } void RenderDirectionalShadows (int index, int tileSize) {} To render shadow we need a ShadowDrawingSettings struct value. We can create a properly-configured one by invoking its constructor method with the culling results and appropriate visible light index, which we stored earlier. void RenderDirectionalShadows (int index, int tileSize) { ShadowedDirectionalLight light = ShadowedDirectionalLights[index]; var shadowSettings = new ShadowDrawingSettings(cullingResults, light.visibleLightIndex); } The idea of a shadow map is that we render the scene from the light's point of view, only storing the depth information. The result tells us how far the light travels before it hits something. However, directional lights are assumed to be infinitely far away and thus don't have a true position. So what we do instead is figure out view and projection matrices that match the light's orientation and gives us a clip space cube that overlaps the area visible to the camera that can contain the light's shadows. Rather than figure this out ourselves we can use the ComputeDirectionalShadowMatricesAndCullingPrimitives method of the culling results to do it for us, passing it nine arguments. The first argument is the visible light index. The next three arguments are two integers and a Vector3 , which control the shadow cascade. We'll deal with cascades later, so for now use zero, one, and the zero vector. After that comes the texture size, for which we need to use the tile size. The sixth argument is the shadow near plane, which we'll ignore and set to zero for now. Those were the input arguments, the remaining three are output arguments. First is the view matrix, then the projection matrix, and the last argument is a ShadowSplitData struct. var shadowSettings = new ShadowDrawingSettings(cullingResults, light.visibleLightIndex); cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives( light.visibleLightIndex, 0, 1, Vector3.zero, tileSize, 0f, out Matrix4x4 viewMatrix, out Matrix4x4 projectionMatrix, out ShadowSplitData splitData ); The split data contains information about how shadow-casting objects should be culled, which we have to copy to the shadow settings. And we have to apply the view and projection matrices by invoking SetViewProjectionMatrices on the buffer. cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(…); shadowSettings.splitData = splitData; buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix); We finally schedule drawing of the shadow casters by executing the buffer and then invoking DrawShadows on the context, with the shadows settings passed to it by reference. shadowSettings.splitData = splitData; buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix); ExecuteBuffer(); context.DrawShadows(ref shadowSettings);

Shadow Caster Pass At this point shadow casters should get rendered, but the atlas remains empty. That's because DrawShadows only renders objects with materials that have a ShadowCaster pass. So add a second Pass block to our Lit shader, with its light mode set to ShadowCaster. Use the same target level, give it support for instancing, plus the _CLIPPING shader feature. Then make it use special shadow-caster functions, which we'll define in a new ShadowCasterPass HLSL file. Also, because we only need to write depth disable writing color data, by adding ColorMask 0 before the HLSL program. SubShader { Pass { Tags { "LightMode" = "CustomLit" } … } Pass { Tags { "LightMode" = "ShadowCaster" } ColorMask 0 HLSLPROGRAM #pragma target 3.5 #pragma shader_feature _CLIPPING #pragma multi_compile_instancing #pragma vertex ShadowCasterPassVertex #pragma fragment ShadowCasterPassFragment #include "ShadowCasterPass.hlsl" ENDHLSL } } Create the ShadowCasterPass file by duplicating LitPass and removing everything that isn't necessary for shadow casters. So we only need the clip-space position, plus the base color for clipping. The fragment function has nothing to return so becomes void without semantics. The only thing it does is potentially clip fragments. #ifndef CUSTOM_SHADOW_CASTER_PASS_INCLUDED #define CUSTOM_SHADOW_CASTER_PASS_INCLUDED #include "../ShaderLibrary/Common.hlsl" TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); UNITY_INSTANCING_BUFFER_START(UnityPerMaterial) UNITY_DEFINE_INSTANCED_PROP(float4, _BaseMap_ST) UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor) UNITY_DEFINE_INSTANCED_PROP(float, _Cutoff) UNITY_INSTANCING_BUFFER_END(UnityPerMaterial) struct Attributes { float3 positionOS : POSITION; float2 baseUV : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct Varyings { float4 positionCS : SV_POSITION; float2 baseUV : VAR_BASE_UV; UNITY_VERTEX_INPUT_INSTANCE_ID }; Varyings ShadowCasterPassVertex (Attributes input) { Varyings output; UNITY_SETUP_INSTANCE_ID(input); UNITY_TRANSFER_INSTANCE_ID(input, output); float3 positionWS = TransformObjectToWorld(input.positionOS); output.positionCS = TransformWorldToHClip( positionWS ); float4 baseST = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseMap_ST); output.baseUV = input.baseUV * baseST.xy + baseST.zw; return output; } void ShadowCasterPassFragment (Varyings input) { UNITY_SETUP_INSTANCE_ID(input); float4 baseMap = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.baseUV); float4 baseColor = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor); float4 base = baseMap * baseColor; #if defined(_CLIPPING) clip(base.a - UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff)); #endif } #endif We're now able to render shadow casters. I created a simple test scene containing some opaque objects on top of a plane, with one directional light that has shadows enabled at full strength to try it out. It' doesn't matter whether the light is set to use hard or soft shadows. Shadow test scene. Shadows don't affect the final rendered image yet, but we can already see what get's rendered into the the shadow atlas via the frame debugger. It's usually visualized as a monochrome texture, going from white to black as distance increases, but it's red and goes the other way when using OpenGL. 512 atlas; max distance 100. With the max shadow distances set to 100 we end up with everything rendered to only a small portion of the texture. Reducing the max distance effectively makes the shadow map zoom in on what's in front of the camera. Max distance 20 and 10. Note that the shadow casters are rendered with an orthographic projection, because we're rendering for a directional light.