Custom Light Spread in Unity.

Custom Light Spread in Unity.

Let's imagine we have a pretty large light source.

Now, we want to increase its intensity so that it can illuminate more space. Increasing the intensity will not only light the edges (which is what we want), but it will also cause over-burning at the center.

I've been struggling with this for ages until I finally decided to dig deep into the custom lighting model in Unity. My intention was to limit the light overburn while also influencing the spread of the light overall.

And, jumping ahead, that turned out to be quite an easy task.

So, if a standard shader looks like this:

#pragma surface surf Standard fullforwardshadows

...

void surf(Input IN, inout SurfaceOutputStandard o)

We need to modify it to look like this:

#pragma surface surf Custom fullforwardshadows

...

void surf(Input IN, inout SurfaceOutput o)

Also, we need to define our custom light function (which will fall back to Lambert for now) and a global illumination function.

float4 LightingCustom(SurfaceOutput s, float3 viewDir, UnityGI gi)
{
	float4 c = LightingLambert(s, gi);
	return c;
}

inline void LightingCustom_GI(SurfaceOutput s, UnityGIInput data,
                              inout UnityGI gi)
{
	gi = UnityGI_Base(data, 1, s.Normal);
}

So, the complete working shader is as follows:

Full shader code (minimal working version).
Shader "Custom/CustomLight"
{
	Properties
	{
		_Color ("Color", Color) = (1,1,1,1)
		_MainTex ("Albedo (RGB)", 2D) = "white" {}
		_Smoothness ("Smoothness", Range(0,1)) = 0.5
		_Metallic ("Metallic", Range(0,1)) = 0.0
	}
	SubShader
	{
		Tags
		{
			"RenderType"="Opaque"
		}
		LOD 200

		CGPROGRAM
		#pragma surface surf Custom fullforwardshadows

		#pragma target 3.5

		sampler2D _MainTex;

		struct Input
		{
			float2 uv_MainTex;
		};

		half _Smoothness;
		half _Metallic;
		float4 _Color;

		float4 LightingCustom(SurfaceOutput s, float3 viewDir, UnityGI gi)
		{
			float4 c = LightingLambert(s, gi);
			return c;
		}

		inline void LightingCustom_GI(SurfaceOutput s, UnityGIInput data, inout UnityGI gi)
		{
			gi = UnityGI_Base(data, 1, s.Normal);
		}

		void surf(Input IN, inout SurfaceOutput o)
		{
			float4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color;
			o.Albedo = c.rgb;
			o.Alpha = c.a;
		}
		ENDCG
	}
	FallBack "Diffuse"
}

Looking inside the LightingCustom function, one particularly useful variable is gi.light.color. We can read it and write to it.

Let's introduce a _LightMaxBurn variable and write a simple code to limit maximum illumination:

#include "UnityPBSLighting.cginc"

half _LightMaxBurn;

float4 LightingCustom(SurfaceOutput s, float3 viewDir, UnityGI gi)
{
    half invMaxBurn = 1.0 / _LightMaxBurn;
    if (length(gi.light.color) > _LightMaxBurn)
    	gi.light.color = normalize(gi.light.color*invMaxBurn) * _LightMaxBurn;

    ...
}

That's it! We have limited the maximum light intensity.

Tweaking _LightMaxBurn

Tweaks and improvements.

It is already working fine, but let's make it cooler.

Modifying the light spread curve.

What if we'd like not only to limit the maximum light intensity, but also adjust the formula of light spreading from the center to edges?

Let's add one more parameter and some simple math.

_LightMaxBurn("Light Max Burn", Range(0, 10)) = 10
_LightSpread ("Light Spread",   Range(0, 5))  = 1

...

half _LightMaxBurn, _LightSpread;

float4 LightingCustom(SurfaceOutput s, float3 viewDir, UnityGI gi)
{
    half invMaxBurn = 1.0 / _LightMaxBurn;
    half3 lightColor = pow(gi.light.color, _LightSpread);
    if (length(lightColor) > _LightMaxBurn)
    	lightColor = normalize(lightColor * invMaxBurn) * _LightMaxBurn;
    gi.light.color = lightColor;

    ...
}

Now we can play with our new parameter and tweak the light spot in any way we'd like.

Tweaking _LightSpread

PS: As my main focus was on limiting the max intensity, I didn't invest much time into this "light spread" math. So, it looks kinda weird, but it shows the possibilities.

Changing the lighting model from Lambert to PBR.

Let's modify our shader so it would use a proper PBR model, not just a simple Lambert one.

For that I had to dig into Unity shader source code and to strip some code out of it (I've taken it from UnityPBSLighting.cginc, LightingStandard() and adapted it to use a SurfaceOutput structure as an input):

float4 CalcPBR(SurfaceOutput s, float3 viewDir, UnityGI gi)
{
	s.Normal = normalize(s.Normal);

	half oneMinusReflectivity;
	half3 specColor;
	s.Albedo = DiffuseAndSpecularFromMetallic(s.Albedo, _Metallic, /*out*/ specColor, /*out*/ oneMinusReflectivity);

	// shader relies on pre-multiply alpha-blend (_SrcBlend = One, _DstBlend = OneMinusSrcAlpha)
	// this is necessary to handle transparency in physically correct way - only diffuse component gets affected by alpha
	half outputAlpha;
	s.Albedo = PreMultiplyAlpha(s.Albedo, s.Alpha, oneMinusReflectivity, /*out*/ outputAlpha);

	half4 c = UNITY_BRDF_PBS(s.Albedo, specColor, oneMinusReflectivity, _Smoothness, s.Normal, viewDir, gi.light, gi.indirect);
	c.a = outputAlpha;
	return c;
}

And then just replace LightingLambert() with CalcPBR().

More ideas to implement.

Here are some useful trick you may need:

  • If you want to implement our custom logics only for spot lights leaving directional lights as it was before, you can wrap the code with
    #ifndef USING_DIRECTIONAL_LIGHT.
  • You can implement some other fancy effects in the same way. For example, light dithering.

The complete shader code.

And here is the final shader code that you can copy & paste and experiment with. Enjoy!

Full shader code (full version).
Shader "Custom/CustomLight"
{
	Properties
	{
		_Color ("Color", Color) = (1,1,1,1)
		_MainTex ("Albedo (RGB)", 2D) = "white" {}
		_Smoothness ("Smoothness", Range(0,1)) = 0.5
		_Metallic ("Metallic", Range(0,1)) = 0.0
		
		_LightMaxBurn("Light Max Burn", Range(0, 10)) = 10
		_LightSpread ("Light Spread",   Range(0, 5))  = 1
	}
	SubShader
	{
		Tags
		{
			"RenderType"="Opaque"
		}
		LOD 200

		CGPROGRAM
		#pragma surface surf Custom fullforwardshadows

		#pragma target 3.5

		#include "UnityPBSLighting.cginc"

		sampler2D _MainTex;

		struct Input
		{
			float2 uv_MainTex;
		};

		half _Smoothness;
		half _Metallic;
		float4 _Color;

		half _LightMaxBurn, _LightSpread;

		float4 CalcPBR(SurfaceOutput s, float3 viewDir, UnityGI gi)
		{
			s.Normal = normalize(s.Normal);

			half oneMinusReflectivity;
			half3 specColor;
			s.Albedo = DiffuseAndSpecularFromMetallic(s.Albedo, _Metallic, /*out*/ specColor, /*out*/ oneMinusReflectivity);

			// shader relies on pre-multiply alpha-blend (_SrcBlend = One, _DstBlend = OneMinusSrcAlpha)
			// this is necessary to handle transparency in physically correct way - only diffuse component gets affected by alpha
			half outputAlpha;
			s.Albedo = PreMultiplyAlpha(s.Albedo, s.Alpha, oneMinusReflectivity, /*out*/ outputAlpha);

			half4 c = UNITY_BRDF_PBS(s.Albedo, specColor, oneMinusReflectivity, _Smoothness, s.Normal, viewDir, gi.light, gi.indirect);
			c.a = outputAlpha;
			return c;
		}
		

		float4 LightingCustom(SurfaceOutput s, float3 viewDir, UnityGI gi)
		{
		    half invMaxBurn = 1.0 / _LightMaxBurn;
		    half3 lightColor = pow(gi.light.color, _LightSpread);
		    if (length(lightColor) > _LightMaxBurn)
    			lightColor = normalize(lightColor * invMaxBurn) * _LightMaxBurn;
		    gi.light.color = lightColor;
			
			float4 c = CalcPBR(s, viewDir, gi);
			return c;
		}

		inline void LightingCustom_GI(SurfaceOutput s, UnityGIInput data, inout UnityGI gi)
		{
			gi = UnityGI_Base(data, 1, s.Normal);
		}

		void surf(Input IN, inout SurfaceOutput o)
		{
			float4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color;
			o.Albedo = c.rgb;
			o.Alpha = c.a;
		}
		ENDCG
	}
	FallBack "Diffuse"
}