An X-ray effect without touching your existing shaders

Hi there,

I am a big fan of Youtube's Makin' Stuff Look Good channel and this video about x-ray shading was specially useful to me since I was using it in my own game.

The objective of x-ray vision is to simply show some set of occluded meshes through some kind of special effect, in this case rim lightning. To achieve that you need to have two kinds of meshes, obstacles that write to the stencil buffer, and "xray" objects that use the stencil buffer to enable or disable the rim lightning effect for each of its fragments. The xray effect is rendered by a second camera that uses a replacement shader to only deal with the "xray" meshes.

In short, the technique used in the video requires you to change all of your obstacle meshes's shaders to write to the stencil buffer and all of your "xray" objects's shaders to have a special tag for shader replacement. I didn't really fancy the idea of having to change every shader that I want to use in order to achieve that effect, so I began thinking in ways in which we could get the same effect without touching any existing shader.

The solution I came up with is to have 3 cameras (instead of 2 as demonstrated in the video)

The Main Camera is just the normal scene camera with two child cameras.

The first effect camera ("obstacles camera", depth 0) renders only the objects marked as obstacles with a stencil write shader

Shader "XRay Shaders/Stencil-Write"  
{

    SubShader
    {
        Tags {
         "Queue" = "Transparent-1"
            "RenderType" = "Transparent"
         }

        Blend Zero One
        ZWrite Off

        Stencil
        {
            Ref 1
            Comp Always
            Pass Replace
            ZFail Keep
        }
        Pass {
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            struct appdata
            {
                float4 vertex : POSITION;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = mul(UNITY_MATRIX_MVP, v.vertex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                return fixed4(0.0,0.0,0.0,0.0);
            }

            ENDCG
        }
    }
}

which basically sets all the pixels of the stencil buffer where that obstacle is the visible to 1. Note the color blending weirdness. Since we only want to affect the stencil buffer and not the color buffer, I ended up with a no-op blending. I'm still trying to find a way to have a stencil-only shader but haven't figure it out yet.

Then the second effect camera ("xray camera", depth 1), just renders all "x-ray" objects (entities with the "unit" tag in my case) with the x-ray coloured outline shader used in the video.

Shader "XRay Shaders/ColoredOutline"  
{
    Properties
    {
        _EdgeColor("Edge Color", Color) = (1,0,0,1)
    }

    SubShader
    {
        Stencil
        {
            Ref 0
            Comp NotEqual
        }

        Tags
        {
            "Queue" = "Transparent"
            "RenderType" = "Transparent"
        }

        ZWrite Off
        ZTest Always
        Blend One One

        Pass
        {

            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

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

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
                float3 normal : NORMAL;
                float3 viewDir : TEXCOORD1;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = mul(UNITY_MATRIX_MVP, v.vertex);
                o.uv = v.uv;
                o.normal = UnityObjectToWorldNormal(v.normal);
                o.viewDir = normalize(_WorldSpaceCameraPos.xyz - mul(_Object2World, v.vertex).xyz);
                return o;
            }

            float4 _EdgeColor;

            fixed4 frag (v2f i) : SV_Target
            {
                float NdotV = 1 - dot(i.normal, i.viewDir) * 1.5;
                return NdotV * _EdgeColor;
            }

            ENDCG
        }
    }
}

With that, we end up with this final effect in the end

without having to mess around with custom standard shaders.

Of course, this method is slower since you have to run the Stencil-Write shader for every obstacle, which is kind of a waste. But hey, now I can just forget about it and keep adding new materials and shaders without having to worry about how they would match with the the x-ray effect.

As always, you can get in touch with me through email, LinkedIn or Twitter_.