Custom SRP 2.3.0
Shadow Textures
- Let the render graph manage shadow textures.
- Merge lighting code into lighting pass.
This tutorial is made with Unity 2022.3.12f1 and follows Custom SRP 2.2.0.
Managed Shadow Textures
In the previous tutorial we let the render graph manage the camera textures. This time we also let it manage the shadow textures.
Create a We'll also let We can indicate that our depth textures are specifically for shadow maps, by setting the To make this work we have to set up lighting and shadows while recording the graph, not while executing it. So we remove the And add it to Also, we no longer have to deal with missing textures in Now that there are no more textures to release up we can remove The next step is to adapt Both We currently set the lights-per-object shader keyword in Instead we do it in However, that method requires a As Moving on to the passes, And This pass should not be culled if the shadow textures aren't used anywhere else, because it also sets up all GPU lighting data. So disable pass culling for it. The shadow textures are read by To tie it all together, adapt Finally, we can get rid of the lighting data here.Shadows
ShadowTextures ref struct type, like CameraRendererTextures but with only two texture handles, for the directional and other shadow atlases.using UnityEngine.Experimental.Rendering.RenderGraphModule;
public readonly ref struct ShadowTextures
{
public readonly TextureHandle directionalAtlas, otherAtlas;
public ShadowTextures(
TextureHandle directionalAtlas,
TextureHandle otherAtlas)
{
this.directionalAtlas = directionalAtlas;
this.otherAtlas = otherAtlas;
}
}
Shadows will use these handles instead of its current render textures, so give it fields for them. TextureHandle directionalAtlas, otherAtlas;
Shadows create and register the handles, via a new GetRenderTextures method that returns the shadow textures. It needs the render graph and a builder as parameters. To keep things simple we'll always provide valid handles. If an atlas is not needed we use the defaultShadowTexture from the graph's defaultResources. That refers to a 1×1 shadow texture that always exists. public ShadowTextures GetRenderTextures(
RenderGraph renderGraph,
RenderGraphBuilder builder)
{
int atlasSize = (int)settings.directional.atlasSize;
var desc = new TextureDesc(atlasSize, atlasSize)
{
depthBufferBits = DepthBits.Depth32,
name = "Directional Shadow Atlas"
};
directionalAtlas = shadowedDirLightCount > 0 ?
builder.WriteTexture(renderGraph.CreateTexture(desc)) :
renderGraph.defaultResources.defaultShadowTexture;
atlasSize = (int)settings.other.atlasSize;
desc.width = desc.height = atlasSize;
desc.name = "Other Shadow Atlas";
otherAtlas = shadowedOtherLightCount > 0 ?
builder.WriteTexture(renderGraph.CreateTexture(desc)) :
renderGraph.defaultResources.defaultShadowTexture;
return new ShadowTextures(directionalAtlas, otherAtlas);
}
isShadowMap field of TextureDesc to true. That avoids the allocation of a stencil buffer. var desc = new TextureDesc(atlasSize, atlasSize)
{
depthBufferBits = DepthBits.Depth32,
isShadowMap = true,
name = "Directional Shadow Atlas"
};
RenderGraphContext parameter from Setup. public void Setup(
//RenderGraphContext context,
CullingResults cullingResults, ShadowSettings settings)
{
//buffer = context.cmd;
//this.context = context.renderContext;
…
}Render instead. public void Render(RenderGraphContext context)
{
buffer = context.cmd;
this.context = context.renderContext;
…
}
Render and instead always set the global textures using the handles. if (shadowedDirLightCount > 0)
{
RenderDirectionalShadows();
}
//else { … }
if (shadowedOtherLightCount > 0)
{
RenderOtherShadows();
}
//else { … }
buffer.SetGlobalTexture(dirShadowAtlasId, directionalAtlas);
buffer.SetGlobalTexture(otherShadowAtlasId, otherAtlas);RenderDirectionalShadows and RenderOtherShadows must use the handles are as well, instead of getting temporary textures. void RenderDirectionalShadows()
{
…
// buffer.GetTemporaryRT(…);
buffer.SetRenderTarget(
directionalAtlas,
RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store);
…
}
…
void RenderOtherShadows()
{
…
// buffer.GetTemporaryRT(…);
buffer.SetRenderTarget(
otherAtlas,
RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store);
…
Cleanup.
//public void Cleanup() { … }Lighting
Lighting. We simplify its Setup method so it does not immediately render shadows. As rendering is delayed we have to keep track of dirLightCount, otherLightCount, and useLightsPerObject in fields. int dirLightCount, otherLightCount;
bool useLightsPerObject;
public void Setup(
//RenderGraphContext context,
CullingResults cullingResults, ShadowSettings shadowSettings,
bool useLightsPerObject, int renderingLayerMask)
{
//buffer = context.cmd;
this.cullingResults = cullingResults;
this.useLightsPerObject = useLightsPerObject;
shadows.Setup(//context,
cullingResults, shadowSettings);
SetupLights(//useLightsPerObject,
renderingLayerMask);
//shadows.Render();
//context.renderContext.ExecuteCommandBuffer(buffer);
//buffer.Clear();
}dirLightCount and otherLightCount are set to zero in SetupLight and the code that uses the buffer gets split into a new Render method. The removed shadow-rendering code gets reinserted at its end. void SetupLights(
//bool useLightsPerObject,
int renderingLayerMask)
{
…
//int dirLightCount = 0, otherLightCount = 0;
dirLightCount = otherLightCount = 0;
…
}
public void Render(RenderGraphContext context)
{
CommandBuffer buffer = context.cmd;
buffer.SetGlobalInt(dirLightCountId, dirLightCount);
if (dirLightCount > 0) { … }
…
shadows.Render(context);
context.renderContext.ExecuteCommandBuffer(buffer);
buffer.Clear();
}SetupLights directly, but it's better to do this via a command buffer, so remove that code. if (useLightsPerObject)
{
…
//Shader.EnableKeyword(lightsPerObjectKeyword);
}
//else { … }Render, by invoking the buffer's SetKeyword method. CommandBuffer buffer = context.cmd;
buffer.SetKeyword(lightsPerObjectKeyword, useLightsPerObject);
buffer.SetGlobalInt(dirLightCountId, dirLightCount);
GlobalKeyword parameter instead of string. So change our keyword to that, by invoking GlobalKeyword.Create with the string as an argument when initializing the static field. An added benefit of using GlobalKeyword is that the keyword name has to be used only once to look up its ID. static readonly GlobalKeyword lightsPerObjectKeyword =
GlobalKeyword.Create("_LIGHTS_PER_OBJECT");
Lighting encapsulates Shadows add a method to get the shadow textures, passing through the required arguments. public ShadowTextures GetShadowTextures(
RenderGraph renderGraph, RenderGraphBuilder builder) =>
shadows.GetRenderTextures(renderGraph, builder);
Cleanup only forwarded its invocation to Shadows, so this method can be removed.
//public void Cleanup() { … }Lighting Pass
LightingPass can now construct its lighting object by itself and no longer needs to keep track of the other lighting data. readonly Lighting lighting = new();
//CullingResults cullingResults;
//ShadowSettings shadowSettings;
//bool useLightsPerObject;
//int renderingLayerMask;Render now forwards to the lighting's Render method. void Render(RenderGraphContext context) => lighting.Render(context);
Record no longer needs a lighting parameter, invokes Setup on the lighting data of the pass, and returns the shadow textures, using its builder. public ShadowTextures Record(
RenderGraph renderGraph,
//Lighting lighting,
CullingResults cullingResults, ShadowSettings shadowSettings,
bool useLightsPerObject, int renderingLayerMask)
{
using RenderGraphBuilder builder = renderGraph.AddRenderPass(
sampler.name, out LightingPass pass, sampler);
//pass.lighting = lighting;
//pass.cullingResults = cullingResults;
//pass.shadowSettings = shadowSettings;
//pass.useLightsPerObject = useLightsPerObject;
//pass.renderingLayerMask = renderingLayerMask;
pass.lighting.Setup(cullingResults, shadowSettings,
useLightsPerObject, renderingLayerMask);
builder.SetRenderFunc<LightingPass>((pass, context) => pass.Render(context));
return pass.lighting.GetShadowTextures(renderGraph, builder);
} builder.AllowPassCulling(false);
return pass.lighting.GetShadowTextures(renderGraph, builder);
Geometry Pass
GeometryPass, so add a ShadowTextures parameter to its Record method and indicate that both atlases are read from. public static void Record(
…
in CameraRendererTextures textures,
in ShadowTextures shadowTextures)
{
…
builder.ReadTexture(shadowTextures.directionalAtlas);
builder.ReadTexture(shadowTextures.otherAtlas);
builder.SetRenderFunc<GeometryPass>(
(pass, context) => pass.Render(context));
}
Camera Renderer
CameraRenderer.Render to work with the new approach. It gets the shadow textures from LightingPass.Record and no longer passes it the lighting object. It provides the shadows textures when recording both geometry passes. It also no longer has to clean up lighting after executing the graph. ShadowTextures shadowTextures = LightingPass.Record(
renderGraph,
//lighting,
…);
…
GeometryPass.Record(…, shadowTextures);
…
GeometryPass.Record(…, shadowTextures);
…
//lighting.Cleanup();
//readonly Lighting lighting = new();
Cleanup
We've transitioned to using graph-managed shadow textures, but let's also do a bit more code cleanup. I reformatted all C# code to enforce a maximum line width of 80 characters, but I won't show those changes. Besides that, two other things are worth mentioning.
Shadow Keywords
First, Shadows uses four arrays for setting global shader keywords. Let's change these to also work with GlobalKeyword instead of string.
static readonly GlobalKeyword[] directionalFilterKeywords = {
GlobalKeyword.Create("_DIRECTIONAL_PCF3"),
GlobalKeyword.Create("_DIRECTIONAL_PCF5"),
GlobalKeyword.Create("_DIRECTIONAL_PCF7"),
};
static readonly GlobalKeyword[] otherFilterKeywords = {
GlobalKeyword.Create("_OTHER_PCF3"),
GlobalKeyword.Create("_OTHER_PCF5"),
GlobalKeyword.Create("_OTHER_PCF7"),
};
static readonly GlobalKeyword[] cascadeBlendKeywords = {
GlobalKeyword.Create("_CASCADE_BLEND_SOFT"),
GlobalKeyword.Create("_CASCADE_BLEND_DITHER"),
};
static readonly GlobalKeyword[] shadowMaskKeywords = {
GlobalKeyword.Create("_SHADOW_MASK_ALWAYS"),
GlobalKeyword.Create("_SHADOW_MASK_DISTANCE"),
};
The only other change needed is to adjust SetKeywords to match, also making use of CommandBuffer.SetKeyword to simplifies the code.
void SetKeywords(GlobalKeyword[] keywords, int enabledIndex)
{
for (int i = 0; i < keywords.Length; i++)
{
buffer.SetKeyword(keywords[i], i == enabledIndex);
}
}
Merging Lighting Code
Second, as only LightingPass uses Lighting let's merge the latter into the former, because that's where the code belongs now. This requires LightingPass to also use the Unity.Collections and UnityEngine namespaces.
using Unity.Collections; using UnityEngine; using UnityEngine.Experimental.Rendering.RenderGraphModule; using UnityEngine.Rendering;
Delete its lighting object field and its Render method.
//readonly Lighting lighting = new();…//void Render(RenderGraphContext context) => lighting.Render(context);
Then copy everything except GetShadowTextures from Lighting to LightingPass and delete the Lighting script asset. The only change needed for the copied code is making Render private.
//publicvoid Render(RenderGraphContext context) { … }
The last step is to remove the indirection via lighting from Record.
//pass.lighting.Setuppass.Setup(cullingResults, shadowSettings, useLightsPerObject, renderingLayerMask); builder.SetRenderFunc<LightingPass>( (pass, context) => pass.Render(context)); builder.AllowPassCulling(false); return pass.shadows.GetRenderTextures(renderGraph, builder);
The next tutorial is Custom SRP 2.4.0.