Shader Graph Custom Lighting (shadows for unlit graph)

Hey! Wrote this up as an answer to someone on the Unity Discord. I plan to extend this post further at a later date, but for now it’s a bit of information about getting shadows to work in an Unlit Graph (basically this tweet thread but in post form and with improvements).

I recommend reading the entire post before trying stuff out, as using the alternative code at the bottom with two keywords is the current best solution I’ve found to get shadows working without awkward hacks.

Update : I’ve also shared some custom lighting functions on github! Includes a bunch of lighting related subgraphs. The Main Light Shadows one will work in unlit, it uses the “alternative code with two keywords” solution as explained in this post.

Keywords

So, material.EnableKeyword allows you to enable keywords for a particular material. You’d use it in a script with the material reference. For example something like :

public class Example : MonoBehaviour {

    public Material material;

    void Start(){
        material.EnableKeyword("SOME_KEYWORD");
        material.EnableKeyword("MAIN_LIGHT_CALCULATE_SHADOWS");
        // etc
    }

}

Probably can also add [ExecuteAlways] before the class so it works in edit mode too. There’s also Shader.EnableKeyword which is a static method and does it globally. There’s also DisableKeyword.

Just enabling a keyword isn’t enough to magically make it work though. The shader is responsible for how those keywords are used. Usually, we use multi_compile and shader_feature in code to define a keyword which allows us to toggle effects on and off in a shader. (This can be done in shadergraph too, using the Keywords in the blackboard).

Once we have one of those set up, e.g. SOME_KEYWORD, we can use it by doing something like :

#ifdef SOME_KEYWORD  // short for "#if defined(SOME_KEYWORD)"
// do this code
#else
// do nothing, or do some other code instead
#endif

Same in shader graph, but it’s a node with an On and Off state.

It’s useful as we can ensure some particular code is, or isn’t included in the shader, without manually creating two separate shaders. Especially useful if those calculations are complex and some materials might need them while others won’t, like shadow calculations.

There are some problems, as there is a maximum keyword count and for every boolean keyword defined it doubles the amount of shader variants, which affects build time – but not going into that now. You can read more here : https://docs.unity3d.com/Manual/SL-MultipleProgramVariants.html

Main Light Shadows

Specifically when it comes to main light shadows in URP, you are supposed to define :

#pragma multi_compile _ _MAIN_LIGHT_SHADOWS
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE
#pragma multi_compile _ _SHADOWS_SOFT

We could add these via Shader Graph’s Keywords… (but for the PBR Graph they are already defined for us). However, in an Unlit Graph, defining _MAIN_LIGHT_SHADOWS causes an error as the code later expects the shadowCoord to be passed from the vertex to fragment stages – and it doesn’t exist. We can’t add it in unless we were to edit the generated code, and that’s a bit of a mess.

Luckily, that keyword isn’t actually used in URP’s ShaderLibrary Shadows.hlsl, except to also define MAIN_LIGHT_CALCULATE_SHADOWS which is then used to ensure those shadow calculations take place. And that keyword doesn’t error! It’s a bit of a hack, but it works… currently at least.

Where the custom function is :

void MainLight_float (float3 WorldPos, out float3 Direction, out float3 Color,
	out float DistanceAtten, out float ShadowAtten){

#ifdef SHADERGRAPH_PREVIEW
	Direction = normalize(float3(1,1,-0.4));
	Color = float4(1,1,1,1);
	DistanceAtten = 1;
	ShadowAtten = 1;
#else
	float4 shadowCoord = TransformWorldToShadowCoord(WorldPos);
	Light mainLight = GetMainLight(shadowCoord);

	Direction = mainLight.direction;
	Color = mainLight.color;
	DistanceAtten = mainLight.distanceAttenuation;
	ShadowAtten = mainLight.shadowAttenuation;
#endif

}

I have this in a Sub Graph so I can easily add it to any other unlit graph, but it would work in the main graph too. For an example of how you’d use this further, see Unity’s article on custom lighting here. (I’ll write a similar thing here in the future)

The other two keywords work fine if defined in Shader Graph, and will also be set automatically by URP as long as they are Multi Compile & Global. But our hackish keyword of MAIN_LIGHT_CALCULATE_SHADOWS isn’t set automatically since it expects the other one. In order to enable it, we can either switch to the debug inspector and enter it manually (as shown in this tweet thread), or we can use material.EnableKeyword from a C# script as mentioned at the start.

With changes to Shader Graph / URP, this method could potentially break though… so be aware that you may need to fix things later if that does happen.

Alternative

The alternative, is bypassing those keywords entirely – or at the very least, bypassing the hackish one. Basically, this involves the “// do this code” from earlier, but copying it out of the #ifdef. In the case of the shadows, it’s editing the custom function to this :

void MainLight_float (float3 WorldPos, out float3 Direction, out float3 Color,
	out float DistanceAtten, out float ShadowAtten){

#ifdef SHADERGRAPH_PREVIEW
	Direction = normalize(float3(1,1,-0.4));
	Color = float4(1,1,1,1);
	DistanceAtten = 1;
	ShadowAtten = 1;
#else
	Light mainLight = GetMainLight();
	Direction = mainLight.direction;
	Color = mainLight.color;
	DistanceAtten = mainLight.distanceAttenuation;

	float4 shadowCoord = TransformWorldToShadowCoord(WorldPos);
	// or if cascades are needed (and _MAIN_LIGHT_SHADOWS_CASCADE isn't defined) :
	// half cascadeIndex = ComputeCascadeIndex(WorldPos);
	// float4 shadowCoord = mul(_MainLightWorldToShadow[cascadeIndex], float4(WorldPos, 1.0));

	ShadowSamplingData shadowSamplingData = GetMainLightShadowSamplingData();
	float shadowStrength = GetMainLightShadowStrength();
	ShadowAtten = SampleShadowmap(shadowCoord, TEXTURE2D_ARGS(_MainLightShadowmapTexture, sampler_MainLightShadowmapTexture), shadowSamplingData, shadowStrength, false);
#endif
}

There’s two options here :

Either we use the custom function code without adding any keywords to the graph. This would work fine as long as we don’t have shadow cascades or need to support soft shadows. If we need shadow cascades, we could manually adjust the function, see the commented part. For soft shadows might want to look at the SampleShadowmapFiltered method for that. But this is a bit awkward to have these hardcoded, and less flexible if we want to change them later.

Another better alternative (and my preferred method), is to use this edited function (without the commented shadow cascades part), along with the _MAIN_LIGHT_SHADOWS_CASCADE and _SHADOWS_SOFT keywords in the graph – as Global Multi Compile keywords, see image from earlier. With this setup, we still bypass the need for the hackish MAIN_LIGHT_CALCULATE_SHADOWS one, but rely on the the other two which are set automatically by URP. Having this all in a subgraph is even better. With this method we can easily support shadows without needing to enter the debug inspector / enable from C# and the shadow cascades & soft shadows options remain toggleable.

Hope this helps!