Decal Lights and Shadows

Januray 27th, 2025
Mika and the Witch's Mountain

Motivation

When I started working on Mika, I decided to do a complete lighting overhaul. In exteriors, the light from the sun helped give volume to the world. However, in interiors or largely occluded areas there was no lighting information. Everything had an unlit appearance and the shaders didn't support additional lights. The first two changes were:

This improved the game's appearance and allowed us to make more appealing interiors. But there is a catch, which is that each MeshRenderer can only be affected by four additional lights at the same time. Since these lighting features were implemented after the game world had been modelled, many meshes occupied a very large space of the world, which makes placing lights without exceeding the limit hard.

We needed a solution to this problem that didn't require remodelling the entire island just to add some fill lights. This is where decal lights and shadows come into action.

Mika Decal Lights
Decals Off Decals On
Decals Off vs On

Implementation

Decals can be applied in a deferred fashion, so that they only affect the pixels that they're drawn onto instead of an entire mesh. This is the key to render fake lights and shadows:

Decal shadows use the typical transparency formula:


        Blend SrcAlpha OneMinusSrcAlpha
        

Meanwhile, decal lights use a different formula to intensify the scene color based on the shader output:


        Blend DstColor One
        

In both cases, the shader must be configured so that it doesn't write to the depth buffer, front faces are culled and back faces are only rendered when they are behing geometry:


            ZTest GEqual
            Cull Front
            ZWrite Off
        

If you have difficulty understanding what these settings mean, see the image below. In yellow, you can see the part of a sphere that would be visible when it intersects with geometry.

Decal Visualization

Using the depth buffer and some mathematics, it is possible to reconstruct the world position inside of the decal and use it to calculate its opacity.

How to get the local y axis inside the cube for decal shadows (height):


            float rawDepth =  SampleSceneDepth(screenUV);
            float3 positionWS = ComputeWorldSpacePosition(screenUV, rawDepth, UNITY_MATRIX_I_VP);
            half3 positionOS = TransformWorldToObject(positionWS);
            half3 absPositionOS = abs(positionOS);
            half maxCoord = max(absPositionOS.x, max(absPositionOS.y, absPositionOS.z));
            clip(-maxCoord+0.5);
            float height = positionOS.y + 0.5;
        

How to calculate the distance to the sphere center for decal point lights (distSqr):


            float2 screenUV = input.screenPos.xy / input.screenPos.w;
            float rawDepth = SampleSceneDepth(screenUV);
            float3 positionWS = ComputeWorldSpacePosition(screenUV, rawDepth, UNITY_MATRIX_I_VP);
            half3 positionOS = TransformWorldToObject(positionWS);

            half3 dir = positionOS;
            half distSqr = dot(dir, dir);
            clip(1.0-distSqr);

            half atten = LightingDistanceAttenuation(distSqr, 1.0);
        

The game uses the stencil buffer to separate different layers, like water and characters. How this is implemented is out of scope for this article but you can see the code in the last section if you're interested. Thanks to the stencil buffer, it is possible to make decal lights exclude the player or water.

Results

Decal lights and shadows are limitied to simple height gradients and fill light spheres without shadows, but together with a few actual lights and ambient occlusion, they can make environments a lot more appealing. These faked lights and shadows are used to great extent in interiors in the game, especially in the dungeons.

Decals Off Decals On
Decals Off vs On
Decals Off Decals On
Decals Off vs On
Decals Off Decals On
Decals Off vs On
Decals Off Decals On
Decals Off vs On
Decals Off Decals On
Decals Off vs On
Decals Off Decals On
Decals Off vs On
Decals Off Decals On
Decals Off vs On

Going Forward

There is so much more that can be done with decals when it comes to lighting. For this game I really didn't have the time to delve into it, but here are some ideas that come to mind on how to take this lighting system further:

Complete shaders

Take into account that there are also some C# scripts to manage lighting properties using material property blocks.

Decal Shadows:


        Shader "Mika/HeightDecal"
        {
            Properties
            {
                _BaseColor ("Color", Color) = (1,1,1,1)
                _FadingUnits ("Fading Units", Float) = 1
                [KeywordEnum(CUBE, CYLINDER)]
                MASK_SHAPE("Mask Shape", Float) = 0
            }
            SubShader
            {
                Tags {
                    "LightMode" = "UniversalForward"
                    "RenderType"="Transparent"
                    "RenderPipeline" = "UniversalPipeline"
                    "Queue" = "Transparent-99"
                    "UniversalMaterialType" = "Unlit"
                }
    
                Pass
                {
                    Name "passDecalHeight"
                    ZTest GEqual
                    Cull Front
                    ZWrite Off
                    Blend SrcAlpha OneMinusSrcAlpha
            
                    Stencil {
                        Ref 1
                        Comp NotEqual
                        Pass Keep
                    }
        
                    HLSLPROGRAM
                    #pragma vertex vert
                    #pragma fragment frag
                    #pragma multi_compile_fog
                    #pragma multi_compile _ LOD_FADE_CROSSFADE
                    #pragma multi_compile MASK_SHAPE_CUBE MASK_SHAPE_CYLINDER
            
                    #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Color.hlsl"
                    #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Texture.hlsl"
                    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
                    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
                    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
                    #include_with_pragmas "Assets/Shaders/Base Mika style/MikaFunctions_Lighting.hlsl"
            
                    uniform half4 _BaseColor;
                    uniform float _FadingUnitsXPositive;
                    uniform float _FadingUnitsXNegative;
                    uniform float _FadingUnitsZPositive;
                    uniform float _FadingUnitsZNegative;
                    uniform float _Edge0;
                    uniform float _Edge1;
        
                    struct Attributes
                    {
                        float4 positionOS : POSITION;
                    };
            
                    struct Varyings
                    {
                        float4 positionHCS : SV_POSITION;
                        float4 screenPos : TEXCOORD0;
                    };
            
                    Varyings vert (Attributes input)
                    {
                        Varyings output;
                        output.positionHCS = TransformObjectToHClip(input.positionOS.xyz);
                        output.screenPos = ComputeScreenPos(output.positionHCS);
                        return output;
                    }
            
                    SamplerState point_clamp_sampler;
                    float4 _CameraDepthTexture_TexelSize;
            
                    half4 frag (Varyings input) : SV_Target
                    {
                        float2 screenUV = input.screenPos.xy / input.screenPos.w;
                        float rawDepth =  SampleSceneDepth(screenUV);
                        float3 positionWS = ComputeWorldSpacePosition(screenUV, rawDepth, UNITY_MATRIX_I_VP);
                        half3 positionOS = TransformWorldToObject(positionWS);
                
                        half3 absPositionOS = abs(positionOS);
            
                        #ifdef MASK_SHAPE_CUBE
                        half maxCoord = max(absPositionOS.x, max(absPositionOS.y, absPositionOS.z));
                        clip(-maxCoord+0.5);
                        #elif MASK_SHAPE_CYLINDER
                        clip(-absPositionOS.z+0.5);
                        half distToCenter = length(absPositionOS.xy);
                        clip(-distToCenter+0.5);
                        #endif
            
                        float3 cubeScale = float3(
                            length(float3(
                                unity_ObjectToWorld[0].x,
                                unity_ObjectToWorld[1].x,
                                unity_ObjectToWorld[2].x)
                            ), // scale x axis
                            length(float3(
                                unity_ObjectToWorld[0].y,
                                unity_ObjectToWorld[1].y,
                                unity_ObjectToWorld[2].y)
                            ), // scale y axis
                            length(float3(
                                unity_ObjectToWorld[0].z,
                                unity_ObjectToWorld[1].z,
                                unity_ObjectToWorld[2].z)
                            )  // scale z axis
                        );
            
                        float height = positionOS.y + 0.5;
                        float heightFade = smoothstep(_Edge0,_Edge1,height);
                
                        float2 lateralFadeXZ = (absPositionOS.xz * cubeScale.xz);
                        lateralFadeXZ *= 2;
                
                        float2 fadingUnits = float2(
                            positionOS.x > 0 ? _FadingUnitsXPositive : _FadingUnitsXNegative, 
                            positionOS.z > 0 ? _FadingUnitsZPositive : _FadingUnitsZNegative
                        );
                        float2 fadingSpan = fadingUnits*2;
                        float2 lateralFadeStart = cubeScale.xz - fadingSpan;
                
                        lateralFadeXZ -= lateralFadeStart;
                        lateralFadeXZ /= fadingSpan;
                
                        lateralFadeXZ = 1.0 - saturate(lateralFadeXZ);
                        float lateralFade = lateralFadeXZ.x * lateralFadeXZ.y;
                
                        float4 positionHCS = TransformWorldToHClip(positionWS);
                        half fogIntensity = ComputeFogIntensity2(positionHCS.z / positionHCS.w);
                
                        half alpha = _BaseColor.a * fogIntensity * heightFade * lateralFade;
                
                        #if LOD_FADE_CROSSFADE
                        alpha *= unity_LODFade.x;
                        #endif
            
                        return half4(_BaseColor.rgb, alpha);
                    }
                    ENDHLSL
                }
            }
        }
        

Decal Lights:


        Shader "Mika/LightDecal"
        {
            Properties
            {
                [HDR] _BaseColor ("Color", Color) = (1,1,1,1)
                _ReadMask ("Read Mask", Int) = 15
                _Comparison ("Comparison", Int) = 6
            }
            SubShader
            {
                Tags {
                    "LightMode" = "UniversalForward"
                    "RenderType"="Transparent"
                    "RenderPipeline" = "UniversalPipeline"
                    "Queue" = "Transparent-100"
                    "UniversalMaterialType" = "Unlit"
                }
    
                Pass
                {
                    Name "passDecalLight"
                    ZTest GEqual
                    Cull Front
                    ZWrite Off
                    Blend DstColor One
            
                    //In binary, character layer is 1001 and water layer is 0001.
                    //Using a ReadMask of 15 (1111) means that only water is excluded.
                    //Using a ReadMask of 7 (0111) means that both water and characters are excluded.
                    Stencil {
                        Ref 1
                        ReadMask [_ReadMask]
                        Comp [_Comparison]
                        Pass Keep
                    }
            
                    HLSLPROGRAM
                    #pragma vertex vert
                    #pragma fragment frag
                    #pragma multi_compile_fog
                    #pragma multi_compile _ LOD_FADE_CROSSFADE
            
                    #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Color.hlsl"
                    #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Texture.hlsl"
                    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
                    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
                    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
                    #include_with_pragmas "Assets/Shaders/Base Mika style/MikaFunctions_Lighting.hlsl"
            
                    uniform half4 _BaseColor;
            
                    struct Attributes
                    {
                        float4 positionOS : POSITION;
                    };
            
                    struct Varyings
                    {
                        float4 positionHCS : SV_POSITION;
                        float4 screenPos : TEXCOORD0;
                    };
            
                    Varyings vert (Attributes input)
                    {
                        Varyings output;
                        output.positionHCS = TransformObjectToHClip(input.positionOS.xyz);
                        output.screenPos = ComputeScreenPos(output.positionHCS);
                        return output;
                    }
            
                    half4 frag (Varyings input) : SV_Target
                    {
                        float2 screenUV = input.screenPos.xy / input.screenPos.w;
                        float rawDepth = SampleSceneDepth(screenUV);
                        float3 positionWS = ComputeWorldSpacePosition(screenUV, rawDepth, UNITY_MATRIX_I_VP);
                        half3 positionOS = TransformWorldToObject(positionWS);
                
                        half3 dir = positionOS;
                        half distSqr = dot(dir, dir);
                        clip(1.0-distSqr);
                
                        half atten = LightingDistanceAttenuation(distSqr, 1.0);
                
                        float4 positionHCS = TransformWorldToHClip(positionWS);
                        half fogIntensity = ComputeFogIntensity2(positionHCS.z / positionHCS.w);
                
                        half alpha = _BaseColor.a * fogIntensity;
                
                        #if LOD_FADE_CROSSFADE
                        alpha *= unity_LODFade.x;
                        #endif
                
                        return half4(_BaseColor.rgb * atten * alpha, 1);
                    }
                    ENDHLSL
                }
            }
        }