This is the fourth installment of a tutorial series covering Unity's scriptable render pipeline. In it, we'll add support for up to sixteen spotlights with shadows.

This tutorial is made with Unity 2018.3.0f2.

A Spotlight With Shadows

Shadows are very important, both to increase realism and to make the spatial relationship between objects more obvious. Without shadows, it can be hard to tell whether something floats about a surface or sits on top of it.

The Rendering 7, Shadows tutorial explains how shadows work in Unity's default render pipeline, but that exact same approach doesn't work for our single-pass forward renderer. It is still useful to skim to get the gist of shadow maps though. In this tutorial we'll limit ourselves to shadows for spotlights, are they're the least complicated.

We start with supporting exactly one shadowed light, so make a scene that contains a few objects and a single spotlight. A plane object is useful to receive shadows. All objects should use our Lit Opaque material.

A single spotlight, no shadows yet.

Shadow Map There are a few different ways to deal with shadows, but we'll stick to the default approach of using a shadow map. This means that we'll render the scene from the light's point of view. We're only interested in the depth information of this render, because that tells us how far the light reaches before it hits a surface. Anything that's further away lies in shadows. To use a shadow map we have to create it before rendering with the normal camera. To be able to sample the shadow map later, we have to render into a separate render texture instead of the usual frame buffer. Add a RenderTexture field to MyPipeline to keep a reference to the shadow map texture. RenderTexture shadowMap; Create a separate method to render the shadows, with the context as a parameter. The first thing it has to do is get hold of a render texture. We'll do that by invoking the static RenderTexture.GetTemporary method. That either creates a new render texture or reuses an old one that hasn't been cleaned up yet. As we'll most likely need the shadow map every frame, it will get reused all the time. Supply RenderTexture.GetTemporary with our map's width and height, the amount of bits used for the depth channel, and finally the texture format. We'll start with a fixed size of 512×512. We'll use 16 bits for the depth channel, so it is high-precision. As we're creating a shadow map, use the RenderTextureFormat.Shadowmap format. void RenderShadows (ScriptableRenderContext context) { shadowMap = RenderTexture.GetTemporary( 512, 512, 16, RenderTextureFormat.Shadowmap ); } Make sure that the texture's filter mode is set the bilinear and its wrap mode is set to clamp. shadowMap = RenderTexture.GetTemporary( 512, 512, 16, RenderTextureFormat.Shadowmap ); shadowMap.filterMode = FilterMode.Bilinear; shadowMap.wrapMode = TextureWrapMode.Clamp; The shadow map is to be rendered before the regular scene, so invoke RenderShadows in Render before we setup the regular camera, but after culling. void Render (ScriptableRenderContext context, Camera camera) { … CullResults.Cull(ref cullingParameters, context, ref cull); RenderShadows(context); context.SetupCameraProperties(camera); … } Also, make sure to release the render texture when we're done, after we've submitted the context. If we have a shadow map at that point, pass it to the RenderTexture.ReleaseTemporary method and clear our field. void Render (ScriptableRenderContext context, Camera camera) { … context.Submit(); if (shadowMap) { RenderTexture.ReleaseTemporary(shadowMap); shadowMap = null; } }

Shadow Command Buffer We'll use a separate command buffer for all the shadow work, so we can see the shadow and regular rendering in separate sections in the frame debugger. CommandBuffer cameraBuffer = new CommandBuffer { name = "Render Camera" }; CommandBuffer shadowBuffer = new CommandBuffer { name = "Render Shadows" }; The shadow rendering will happen in between BeginSample and EndSample commands, just like we do for regular rendering. void RenderShadows (ScriptableRenderContext context) { shadowMap = RenderTexture.GetTemporary( 512, 512, 16, RenderTextureFormat.Shadowmap ); shadowMap.filterMode = FilterMode.Bilinear; shadowMap.wrapMode = TextureWrapMode.Clamp; shadowBuffer.BeginSample("Render Shadows"); context.ExecuteCommandBuffer(shadowBuffer); shadowBuffer.Clear(); shadowBuffer.EndSample("Render Shadows"); context.ExecuteCommandBuffer(shadowBuffer); shadowBuffer.Clear(); }

Setting the Render Target Before we can render shadows, we first have tell the GPU to render to our shadow map. A convenient way to do this is by invoking CoreUtils.SetRenderTarget with our command buffer and shadow map as arguments. As we start with clearing the map, invoke it before BeginSample so the frame debugger doesn't show and extra nested Render Shadows level. CoreUtils.SetRenderTarget(shadowBuffer, shadowMap); shadowBuffer.BeginSample("Render Shadows"); context.ExecuteCommandBuffer(shadowBuffer); shadowBuffer.Clear(); We only care about the depth channel, so only that channel needs to be cleared. Indicate this by adding ClearFlag.Depth as a third argument to SetRenderTarget . CoreUtils.SetRenderTarget( shadowBuffer, shadowMap , ClearFlag.Depth ); While not necessary, we can also be more precise about the load and storage requirements of our texture. We don't care where it comes from, as we clear it anyway, which we can indicate with RenderBufferLoadAction.DontCare . That makes it possible for tile-based GPUs to be a bit more efficient. And we need to sample from the texture later, so it needs to be kept in memory, which we indicate with RenderBufferStoreAction.Store . Add these as the third and fourth arguments. CoreUtils.SetRenderTarget( shadowBuffer, shadowMap, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store, ClearFlag.Depth ); The clear action for our shadow map now shows up in the frame debugger, before the regular camera render. Clearing the shadow map.

Configuring the View and Projection Matrices The idea is that we render from the point of view of the light source, which means that we're using the spotlight as if it were a camera. Thus, we have to provide appropriate view and projection matrices. We can retrieve these matrices by invoking ComputeSpotShadowMatricesAndCullingPrimitives on our cull results with the light index as an argument. As we only have a single spotlight in the scene, we simply supply zero. The view and projection matrices are made available via two output parameters. Besides that, there is a third ShadowSplitData output parameter. We don't need it, must supply the output argument. shadowBuffer.BeginSample("Render Shadows"); context.ExecuteCommandBuffer(shadowBuffer); shadowBuffer.Clear(); Matrix4x4 viewMatrix, projectionMatrix; ShadowSplitData splitData; cull.ComputeSpotShadowMatricesAndCullingPrimitives( 0, out viewMatrix, out projectionMatrix, out splitData ); Once we have the matrices, set them up by invoking SetViewProjectionMatrices on the shadow command buffer, execute it, and clear it. cull.ComputeSpotShadowMatricesAndCullingPrimitives( 0, out viewMatrix, out projectionMatrix, out splitData ); shadowBuffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix); context.ExecuteCommandBuffer(shadowBuffer); shadowBuffer.Clear();