Inspiration

Full honestly from the start. I've been playing quite a bit of World of Warcraft Classic lately and it has been great opportunity for me to get inspired graphics wise. I was running around in Duskwood, a really dark and moody area of the game when I came across one of their light posts. It had a soft glow surrounding the light source making it look more present. This is an effect I want to recreate for my own game in Unity.


Creating a custom shader

This visual effect requires a custom shader. First I need to determine what results I'm going for.

  • Is in every sense of the way a billboard. Just a plain quad always facing the camera.
  • Does not interact with lightning in the scene. Any actual light from the prop will be handled with Point Lights.
  • Appear to "light up" meshes underneath it.
  • Be non-static / change is subtle ways.


This boils down to an unlit transparent additive shader, billboarded, scaling it correctly and slowly changin it's size back and forth ("breathing").


I'm an old fashioned guy who prefer code over visual programming and so it will be written in ShaderLabs/CG rather than using ShaderForge.


Textures

The following texture is the only external resource needed for the effect. Download it and put it in your Unity project and remeber to enable the alpha channel in the import settings.


Basic shader setup

The basis of the shader is a Unlit shader, created in Unity by Create -> Shader -> Unlit shader. Because of my lack of fantasy I named exactly what it is, "UnlitSoftGlow.shader".


Create a new material based on this shader and apply the HaloGlow.png texture to it's

_MainTex 
property. Then create a single quad (GameObject -> 3D Object -> Quad) and apply the material to it.


Any changes made to the shader will appear as soon as you save it and tabs back into Unity (even if you are running your game in editor mode). If the quad appears as a solidl magenta color, something is wrong with your ShaderLab/CG code and Unity can't compile it any longer. Fix the errors are the shader will start working again.


First I'll remove any references to Unity's fog as I don't use it in my game. Secondly we will add a few new properties to the shader. The property section will be displayed in the inspector when editing a material.


Properties
{
  _MainTex("Texture", 2D) = "white" {}
  _Color("Color", Color) = (1, 1, 1, 1)
  _BreathFactor("Breath factor", Range(0, 1)) = 1
  _BreathSpeed("Breath speed", Range(0, 10)) = 1
}


Now we need to go into the Tags section and alter the queue from Opaque to Transparent. Please see Unity's own documentation for more information about why.

Tags {
  "Queue" = "Transparent"
  "IgnoreProjector" = "True"
  "RenderType" = "Transparent"
  "ForceNoShadowCasting" = "True"
}


The shade will not use fog.

Fog { Mode Off }


Transparent materials can't write to Z-buffer. That would make the scene start to look weird.

ZWrite Off


The blend setting defines how the output of this shader will be blended with what's already present in the back buffer. SrcAlpha in combination with OneMinusSrcAlpha adds what the fragment shaders returns to the value of the back-buffer.

Blend SrcAlpha OneMinusSrcAlpha


Add some new CG variables. Make they have the same name as those in the Properties section and Unity will bind them.

sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _Color;
fixed _BreathFactor;
fixed _BreathSpeed;


Adding a billboard effect

unity_ObjectToWorld 
is a built in helper matrix (is included via
#include "UnityCG.cginc
") that is used to transform vertices from model space to world space. Here we are extracting the scaling from the matrix and puts it in a new pure scaling matrix, leaving behind rotational data. This will make the the mesh is always facing the camera while also scale it nicely.


float3x3 ObjectScale() {
  return float3x3(
    length(unity_ObjectToWorld._m00_m10_m20),
    0,
    0,
    0,
    length(unity_ObjectToWorld._m01_m11_m21),
    0,
    0,
    0,
    length(unity_ObjectToWorld._m02_m12_m22)
  );
}


We need to use the ObjectScale method and also have a way of transforming every vertex in the mesh from its model space into projection space and still billboard it correctly.

float4 Billboard(float3 pos){
    float3 vpos = mul(ObjectScale(), pos);
    float4 worldCoord = float4(unity_ObjectToWorld._m03, unity_ObjectToWorld._m13, unity_ObjectToWorld._m23, 1);
    float4 viewPos = mul(UNITY_MATRIX_V, worldCoord) + float4(vpos, 0);
    float4 outPos = mul(UNITY_MATRIX_P, viewPos);
    return outPos;
}

Original source for this section is from https://en.wikibooks.org/wiki/Cg_Programming/Unity/Billboards.


Well use this method in the vertex shader to transform the vertices. For UV´s, we'll just copy over what the in the mesh.

v2f vert(appdata v)
{
  v2f o;
  o.pos = Billboard(v.vertex.xyz);
  o.uv = v.uv.xy;
  return o;
}


Tweaking the effect

The basic effect is done but it has a few issues. It appears to be clipping right through the mesh where it's rendered.

float4 viewPos = mul(UNITY_MATRIX_V, worldCoord) + float4(vpos, -0.05);

In this line by changing the last value from 0 to a small negative value (-0.05 here). This will make the the mesh being rendered slightly further back in the scene.

The difference between a value of 0 and -0.05. Obsererve the weird edge at the bottom right corner of the lamp on the left image


Adding a "breathing" effect

To make the effect pulsate and "breath" we'll add a new function to alter the transparency of the effect slowly.

float Breath() {
  return (sin(_Time.y * _BreathSpeed) + 1) / 2;
}

This function returns an altered sine curve, moved up from range of (-1, 1) to (0, 1). _BreathSpeed controls how fast the breaths.

Well use that to modify the final output value of the fragment shader. We're using the breathing factor to slowly move between _BreathFactor and 1.0 back and forth. I put _BreathFactor to 0.75f in my material and though it looked nice. While at it, also multiply the final output color with the

_Color 
variable to we cant tint it.

fixed4 frag(v2f i) : SV_Target
{
  fixed4 col = tex2D(_MainTex, i.uv) * _Color * lerp(1, _BreathFactor, Breath());
  return col;
}


"Breathing" effect, speed up to show it.

Complete shader code

Shader "MageQuest/UnlitSoftGlow"
{
   Properties
   {
      _MainTex("Texture", 2D) = "white" {}
      _Color("Color", Color) = (1, 1, 1, 1)
      _BreathFactor("Breath factor", Range(0, 1)) = 1
      _BreathSpeed("Breath speed", Range(0, 10)) = 1
   }

   SubShader
   {
      Tags {
         "Queue" = "Transparent"
         "IgnoreProjector" = "True"
         "RenderType" = "Transparent"
         "ForceNoShadowCasting" = "True"
      }

      Fog { Mode Off }
      ZWrite Off
      Blend SrcAlpha OneMinusSrcAlpha

      Pass
      {
         CGPROGRAM
         #pragma vertex vert
         #pragma fragment frag

         #include "UnityCG.cginc"

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

         struct v2f
         {
            float2 uv : TEXCOORD0;
            float4 pos : SV_POSITION;
         };

         sampler2D _MainTex;
         float4 _MainTex_ST;
         fixed4 _Color;
         fixed _BreathFactor;
         fixed _BreathSpeed;

         float3x3 ObjectScale() {
            return float3x3(
               length(unity_ObjectToWorld._m00_m10_m20),
               0,
               0,
               0,
               length(unity_ObjectToWorld._m01_m11_m21),
               0,
               0,
               0,
               length(unity_ObjectToWorld._m02_m12_m22)
            );
         }

         float4 Billboard(float3 pos){
            float3 vpos = mul(ObjectScale(), pos);
            float4 worldCoord = float4(unity_ObjectToWorld._m03, unity_ObjectToWorld._m13, unity_ObjectToWorld._m23, 1);
            float4 viewPos = mul(UNITY_MATRIX_V, worldCoord) + float4(vpos, -0.05);
            float4 outPos = mul(UNITY_MATRIX_P, viewPos);
            return outPos;
         }

         v2f vert(appdata v)
         {
            v2f o;
            o.pos = Billboard(v.vertex.xyz);
            o.uv = v.uv.xy;

            return o;
         }


         float Breath() {
            return (sin(_Time.y * _BreathSpeed) + 1) / 2;
         }

         fixed4 frag(v2f i) : SV_Target
         {
            fixed4 col = tex2D(_MainTex, i.uv) * _Color * lerp(1, _BreathFactor, Breath());
            return col;
         }
         ENDCG
      }
   }
}