0

Shading Toolbox #2 – Sillhouette! or… Eagle Vision!

Long gone is the time where „chams“ or seeing enemies through walls with wall-hacks was considered playing unfair! Nowadays every second game throws that function at you out of the box! Let’s name some. Assassin’s Creed, Batman, Splinter Cell, heck just to name a few. So, since this seems to be popular, let’s look at a shading technique that we can use to achieve this effect.

 Please note: This article was first published in 2016. Some smaller details might be out of date, the general procedure and shader will however still work as long as it’s converted to the current shading API. 

General Procedure

We will use a 2 Pass Shader for this, which means, that your object will actually be rendered twice. Now, depending on how your graphics framework works and what shading language you use, this might be quite complicated or very simple. So for the sake of simplicity I will only describe the process here and then propose an implementation for Unity3D in the next chapter.

Pass 1

  • Settings:
    • Depthtesting : Greater (Draw only if the Depthbuffer’s value is higher than the current Z value. This will result in this object being drawn on top of all objects that are currently occluding our mesh.
    • Depthwriting: Off (Do not write into the Depthbuffer)
    • Alpha enabled (standard alpha blending)
  • Calculate rim strength (Proximity to edge) for each fragment
  • Calculate an alpha value that is higher the further away from an edge we are and apply it for the current fragment

Pass 2

  • Settings:
    • Depthtesting: Less or Equal
    • Depthwriting: On
    • Alpha: However you need it
  • Shade your object normally

As you can see, the whole thing is nothing more than a pre-pass rim shader that has a different Depthtesting instruction. Simple, yet powerful.

Let us take a look at the calculations we will need in this shader and introduce a few variables.

– our rim factor.

– our rim width, this is a value the user can chose between 0 and 1 and controls the strength of our outlines. A value of 0 will draw no outlines while a value of 1 will fully fill our mesh.

– The alpha value we will need to make the outlines.

 – A constant offset factor (I use 1.3, but read on for why exactly). This will be relevant in one of the following formulas.

Calculating our rim factor is simple. We look upon an edge if our view vector (V), which is the direction our camera faces and looks at the mesh, is perpendicular to a faces normal (N). We can test for perpendicularity (or values that are close to it) by using the dot product. The dot product of two vectors is 0 if the two said vectors are perpendicular, so we will get lower values the closer we are to an edge. We can multiply this dot product by our alpha value, which would result in values near zero close to edges. We are after the opposite effect though, so we will inverse the result of our dot product.

Now let’s look at two different methods to calculate your alpha value. The first one is very straight forward and will give you a hard „wireframe-esque“ edge for your outlines.

This means, if our rim value plus the width is bigger than 1, we apply 1 as our alpha value, otherwise 0. The second method will give us a smooth fade away from the objects edges.

Note that we multiply w by c. We do this because if:  and  then
Which essentialy increases every rim value by 0.3 if we are using the maximum width of 1, otherwise by a fraction of 0.3 depending on our width, resulting in a nice fade. Imagine you look at a sphere. The rim values would get closer to 0 the closer you are to the center of the sphere. Adding 0.3 will ensure that even the center has an opacity of at least 0.3. I usually apply this to an unlit mesh, which gives a nice filled object with a wee bit of transparency. If you want a full opaque mesh when hitting a width of 1, set 

Unity Implementation

We will implement this as a two pass surface shader. So let’s take a look at the structure of a multipass surface shader first (since we evidently lack the „pass“ keyword in surface shaders).

Shader "Custom/ShaderName" {

    Properties {
    // Unity ShaderLab Properties go here
    }

    SubShader {
    // Pass 1
    Tags {TagArray}

    LOD X
    ZTest Y
    Zwrite Z

    CGPROGRAM

    //Shader in here

    ENDCG

    // Pass 2
    Tags {TagArray}

    LOD X
    ZTest Y
    Zwrite Z

    CGPROGRAM

    //Shader in here

    ENDCG
    }

Fallback "ShaderName"

}

This means, that we can simply add passes to our shaders by chaining CGPROGRAM directives. Note though, that each pass you add will render the object another time. This is not at all mobile friendly or when you are counting drawcalls. Use multipass shading only there where you really need it! If you have a lot of objects that can potentially use a multipass shader, but perhaps do not always use one of its passes (for example eagle vision in Assassins Creed), you might think about switching materials on runtime so the multipass shader is used only when it really has to be used. Alright, let’s define some properties first.

Properties {
    _Color ("Color", Color) = (1,1,1,1)
    _MainTex ("Albedo (RGB)", 2D) = "white" {}
    _Glossiness("Smoothness", Range(0,1)) = 0.5
    _Metallic("Metallic", Range(0,1)) = 0.0

    [Toggle]_UseChams("Enable Chams", Float) = 1
    [Toggle]_UseFalloff("Use Smooth Falloff", Float) = 0

    _ChamsWidth("Chams Width", Range(0,1)) = 0.1
    _ChamsColor("ChamsColor",Color) = (0.7,0.7,0,1)
}

Now we need to set up our first pass.

SubShader {

    // Pass 1 Settings
    Tags { "RenderType"="Opaque" "Queue"="Transparent"}

    LOD 200
    ZTest Greater // Draw only if occluded by other object
    ZWrite Off // Dont write into Zbuffer

    // Pass 1, Draw Chams
    CGPROGRAM

    #pragma surface surf Unlit noshadow alpha:fade keepalpha
    #pragma target 3.0

    struct Input {
        float2 uv_MainTex;
        float3 worldPos;
    };

    fixed4 _ChamsColor;
    half _ChamsWidth;
    bool _UseChams;
    bool _UseFalloff;

We define our Tag array, LOD and Depth settings, after which we initialize the Shader, telling it its surface function will be called surf. We further tell it to use the Unlit lighting Model, which we will write in a second, to cast no shadows and to use alpha blending. We add the float3 viewDir to our Input struct. This will ensure that Unity provides us with our cameras viewVector. Then we define our properties again so we can access them from within the CGPROGRAM. The next step is to write our Unlit lighting function. This will ensure that our „Chams“ are not affected by light, thus visible in total darkness. (Think, nightvision!) This is fairly easy to achieve. We already told the shader that our lighting model function will be called „Unlit“, so we can define it as follows:

half4 LightingUnlit(SurfaceOutput s, half3 lightDir, half atten) {
    half4 c;
    c.rgb = s.Albedo;
    c.a = s.Alpha;
    return c;
}

It is important to name the lighting functions in this format: „Lighting[Name]“. So watch out for that. Inside the function we simply pass through our color values without applying any light calculation. Finally, we can define our surf function.

void surf(Input IN, inout SurfaceOutput o) {
    // is this effect active?
    if (_UseChams) {

        // Calculate Proximity to edge
        half rim = 1 - saturate(abs(dot(normalize(_WorldSpaceCameraPos - IN.worldPos), normalize(o.Normal))));

        // Take color from chams color
        fixed4 c = _ChamsColor;
        o.Albedo = c.rgb;

        // decide for a drawing method
        if (_UseFalloff) {
            o.Alpha = (rim - (1-_ChamsWidth*2));
        }
        else {
            o.Alpha = (rim+_ChamsWidth) > 1 ? 1 : 0;
        }
    }
    // it's not
    else{
        // completely transparent
        o.Albedo = 0;
        o.Alpha = 0;
   }
}

ENDCG // End Pass 1

If you look at the formulas above, this part is pretty self explanatory. Now follow everything up by any other surface shader (e.g. the standard surface shader) and you’re ready to go! One thing however you might wonder about.

On another note: Unity supports „viewDir“ in it’s input structure, why do we calculate the viewVector ourselves then? The reason is that while in some projects the viewDir vector worked just fine, I had severe problems with a really weird viewDir in others. It appears to glitch out when changing rendering models, so to be save we calculate it ourselves.

I hope this helped to shed some light into the darker parts of chams shading and thanks for reading!

But wait, where is pass 2? Well, pass two is a standard surface shader without any modification whatsoever, so I decided to leave it out. Creating a new surface shader file will give you everything you need.

The whole shader is available in the Git Repository

Alexander

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.