Adding Point Lighting and Shadows to a custom fragment shader

Hey everyone, I’ve been working on a billboarding shader with some special behaviour for a project of mine, and thanks to some helpful folks on these forums (namely @bgolus - thanks a million!) I’ve ended up with something that does more or less what I’d like.

This fragment shader supports transparent sprites that face the camera all the time, but calculate their z-depth as if they were standing completely upright, to avoid clipping into geometry behind them.

Because I’m apparently a glutton for difficult tasks, I’ve been trying to add support for some basic lighting and shadows, with limited success. Using a single pass and ForwardBase I’ve managed to make something properly responsive to directional lights, but none of the examples I’ve followed seem to allow this transparent shader to register point/spot lights in the environment, or receive shadows cast over it. Likewise, attempts at adding a second ForwardAdd pass haven’t worked out, likely because all of the examples I’ve seen are meant for opaque shaders.

I’ve considered using a surface shader for this, but I’m reasonably certain the billboarding behaviour I want needs to make adjustments in clip space to fake that the billboard is standing upright, which surface shaders don’t expose. If I’m wrong, feel free to correct me.

Ideally, what I’d like to achieve as far as lighting is:

  • Receives light from nearby sources and directional lights, according to its current normals/facing.
  • Receives shadows and draws darker when in areas of shadow, but pixel-perfect accuracy isn’t necessary. Simply tinting the whole sprite if its origin were in darkness would be fine.
  • Does not need to cast shadows. In fact, due to the nature of the billboard in this 3D world, cast stenciled shadows might look very distorted.
  • Specular reflections and shininess aren’t necessary - these are hand-drawn sprites with hand-drawn highlights.

Here’s a shot using placeholder sprites of what I have working now:

Note how directional vertex lighting is working (all the sprites are tinted blue), but shadows and point lights are ignored, despite my attempts in the shader to implement them.

The shader code is below. Am I missing something, or overlooking some workaround for transparent sprites that receive lighting/shadows? Thanks in advance!

Shader "Sprites/Billboard_VLit_ZDepth"
{
    Properties
    {
        _Color("Color", Color) = (1,1,1,1)
        _MainTex("Texture", 2D) = "white" {}
    }

    SubShader
    {
        Tags{ "Queue" = "Transparent" "IgnoreProjector" = "True" "RenderType" = "Transparent" "DisableBatching" = "True" } //"LightMode" = "ForwardBase" }

        ZWrite Off
        Cull Off
        Blend SrcAlpha OneMinusSrcAlpha

        Pass
        {
            Tags{ "LightMode" = "ForwardBase" }

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #pragma multi_compile_fog
            #pragma multi_compile_fwdbase

            #include "UnityCG.cginc"
            #include "AutoLight.cginc"
            #include "Lighting.cginc"
          

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                float3 normal : NORMAL;
                fixed4 color : COLOR;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                float2 uv : TEXCOORD0;
                UNITY_FOG_COORDS(1)
                LIGHTING_COORDS(2, 3)
                SHADOW_COORDS(4)
                fixed4 color : COLOR0;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            fixed4 _Color;

            float rayPlaneIntersection(float3 rayDir, float3 rayPos, float3 planeNormal, float3 planePos)
            {
                float denom = dot(planeNormal, rayDir);
                denom = max(denom, 0.000001); // avoid divide by zero
                float3 diff = planePos - rayPos;
                return dot(diff, planeNormal) / denom;
            }

            v2f vert(appdata v)
            {
                v2f o;
                o.uv = v.uv.xy;

                // billboard mesh towards camera
                float3 vpos = mul((float3x3)unity_ObjectToWorld, v.vertex.xyz);
                float4 worldCoord = float4(unity_ObjectToWorld._m03, unity_ObjectToWorld._m13, unity_ObjectToWorld._m23, 1);
                float4 viewPos = mul(UNITY_MATRIX_V, worldCoord) + float4(vpos, 0);

                o.pos = mul(UNITY_MATRIX_P, viewPos);

                // calculate distance to vertical billboard plane seen at this vertex's screen position
                float3 planeNormal = normalize(float3(UNITY_MATRIX_V._m20, 0.0, UNITY_MATRIX_V._m22));
                float3 planePoint = unity_ObjectToWorld._m03_m13_m23;
                float3 rayStart = _WorldSpaceCameraPos.xyz;
                float3 rayDir = -normalize(mul(UNITY_MATRIX_I_V, float4(viewPos.xyz, 1.0)).xyz - rayStart); // convert view to world, minus camera pos
                float dist = rayPlaneIntersection(rayDir, rayStart, planeNormal, planePoint);

                // calculate the clip space z for vertical plane
                float4 planeOutPos = mul(UNITY_MATRIX_VP, float4(rayStart + rayDir * dist, 1.0));
                float newPosZ = planeOutPos.z / planeOutPos.w * o.pos.w;

                // use the closest clip space z
                #if defined(UNITY_REVERSED_Z)
                o.pos.z = max(o.pos.z, newPosZ);
                #else
                o.pos.z = min(o.pos.z, newPosZ);
                #endif

                //Calculate Lighting
                // get vertex normal in world space
                half3 worldNormal = UnityObjectToWorldNormal(planeNormal); //v.normal
                // dot product between normal and light direction for
                // standard diffuse (Lambert) lighting
                half nl = max(0, dot(worldNormal, _WorldSpaceLightPos0.xyz));
                // factor in the light color
                o.color.rgb = nl * _LightColor0;
                o.color.rgb += ShadeSH9(half4(worldNormal, 1));
                o.color.a = v.color.a;

                UNITY_TRANSFER_FOG(o,o.vertex);
                TRANSFER_VERTEX_TO_FRAGMENT(o);
                TRANSFER_SHADOW(o);
                return o;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                float attenuation = LIGHT_ATTENUATION(i);
                fixed shadow = SHADOW_ATTENUATION(i);
                fixed4 col = tex2D(_MainTex, i.uv) * _Color * i.color * attenuation * shadow;
                //clip(col.a - 0.2); //For if using cutout shader
                UNITY_APPLY_FOG(i.fogCoord, col);

                return col;
            }
            ENDCG
        }
    }
    Fallback "VertexLit"
}

For receive shadows from directional light you need write “shadowcaster pass” with use your vertex shader, i don’t know what needed for other lights types.

Medium Fallback "VertexLit" has a shadow caster pass already, albeit one that won’t work for this shader properly.

There are a few problems here.

Vertex lit shaders by their nature don’t receive shadows. You’re working around this by still calculating the shadows in the fragment shader, which would work if it wasn’t for the second problem.

Transparent queue materials do not receive shadows in Unity’s built in rendering paths. There are now decade old forum threads on that topic. The URP and HDRP finally add support for that (and the URP only recently, it’s been broken since before the LWRP was renamed to URP). If you want to receive shadows, you must use an opaque queue (like Geometry or AlphaTest). If you’re using an opaque queue, you can’t use alpha blending and have things render properly, so you have to use alpha testing (clip(alpha - 0.5)) or alpha to coverage (AlphaToMask On). But that alone won’t solve the problem.

Like @mouurusai mentioned, you need a shadow caster pass ("LightMode"="ShadowCaster"). The shadow caster pass used for both shadow casting and shadow receiving, at least for the main directional light. For this shader you’d need to write a custom one rather than using anything built in. There are built in shadow caster passes that work with alpha testing (alpha to coverage doesn’t work for shadows casters), but it also needs to do the same adjustments to the sprite plane so it matches the rendered mesh exactly.

After that, you’ll still need to add a "LightMode"="ForwardAdd" pass to handle any additional lights beyond the main directional light, which is the only thing the ForwardBase pass handles is the single main directional light and ambient lighting.

Technically there is also a technique for getting the main directional light’s shadow maps for use on transparent objects. But this only works for the main directional light, not point or spot lights. There’s simply no way to make this work with Unity’s built in rendering path unless you write your own lighting system from scratch. You can also make your shader use per vertex point lights either by forcing the shader to use per vertex lighting exclusively, or by setting your point lights to be non-important. However this disables all shadows entirely.

If you don’t want to change all your sprites to be alpha tested, there is one other solution. Cheat.

By that I mean don’t do any lighting on the GPU for sprites. Instead use c# raycasts to trace from the sprite’s center (or the ground) to the lights that are in range and set the sprite / material color manually to mimic the desired look.

1 Like