Normal Mapped Lighting for 2D Sprites in Futile for Unity

This article will describe how to achieve Normal Mapped Lighting for 2D Sprites in Futile/Unity using a lighting normal map and a custom shader.

A full sample project is available here: https://github.com/smashriot/SRNormalLighting

Rocks - Lit - Example

Contents:

Normal Mapped Lighting Shader:

Before starting, make sure your project is using Forward Rendering, which you may change under Player Settings -> Rendering Path = Forward. Also, since the final lighting calculation will use UNITY_LIGHTMODEL_AMBIENT, ensure that the ambient lighting is close to white and intensity is set to desired value (e.g. 0.55), which is set under Edit -> Render Settings -> Ambient Light (Unity 4) or Window -> Lighting -> Ambient Lighting (Unity 5).

Unity 5 Ambient Lighting

The Shader used to achieve the normal mapped lighting is a two Pass Forward Lighting shader. The first pass (ForwardBase) simply renders the diffuse texture without any lighting. The second pass (ForwardAdd) renders the additive lights using a fragment shader that calculates the diffuse/specular components of the light based on the normal light map associated with the main texture.

Shader:

Shader "Futile/SRLighting" { 

    Properties {
        _MainTex ("Base RGBA", 2D) = "white" {}
        _NormalTex ("Normalmap", 2D) = "bump" {}
        _Color ("Diffuse Material Color", Color) = (1.0, 1.0, 1.0, 1.0) 
        _SpecularColor ("Specular Material Color", Color) = (1.0, 1.0, 1.0, 1.0) 
        _Shininess ("Shininess", Float) = 5
    }
    
    SubShader {
        // these are applied to all of the Passes in this SubShader
        ZWrite Off
		ZTest Always
		Fog { Mode Off }
		Lighting On
    	Cull Off
    	
// -------------------------------------
// Base pass:
// -------------------------------------
        Pass {    

            Tags { "LightMode" = "ForwardBase" "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" } 
            Blend SrcAlpha OneMinusSrcAlpha 
		
CGPROGRAM

#pragma vertex vert  
#pragma fragment frag 

#include "UnityCG.cginc"

uniform sampler2D _MainTex;

struct VertexInput {

    float4 vertex : POSITION;
    float4 color : COLOR;
    float4 uv : TEXCOORD0;    
};

struct VertexOutput {

    float4 pos : POSITION;
    float4 color : COLOR;
    float2 uv : TEXCOORD0;
};

VertexOutput vert(VertexInput i){

    VertexOutput o;

    o.pos = mul(UNITY_MATRIX_MVP, i.vertex);
    o.color = i.color; 
    o.uv = float2(i.uv);
    
    return o;
}

float4 frag(VertexOutput i) : COLOR {

    float4 diffuseColor = tex2D(_MainTex, i.uv);
    float3 ambientLighting = float3(UNITY_LIGHTMODEL_AMBIENT) * float3(diffuseColor) * float3(i.color);
    
    return float4(ambientLighting, diffuseColor.a);
}

ENDCG
        }
        
// -------------------------------------
// Lighting Pass: Lights must be set to Important
// -------------------------------------
        Pass {	

            Tags { "LightMode" = "ForwardAdd" "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" }
            Blend One One // additive blending 

CGPROGRAM

#pragma vertex vert  
#pragma fragment frag 

#include "UnityCG.cginc"

// shader uniforms
uniform sampler2D _MainTex;   // source diffuse texture
uniform sampler2D _NormalTex; // normal map lighting texture (set to import type: Lightmap)
uniform float4 _LightColor0;  // color of light source 
uniform float4 _SpecularColor; 
uniform float _Shininess;
            
struct vertexInput {
    float4 vertex : POSITION; 
    float4 color : COLOR;
    float4 uv : TEXCOORD0;  
};

struct fragmentInput {
    float4 pos : SV_POSITION;
    float4 color : COLOR0;
    float2 uv : TEXCOORD0;
    float4 posWorld : TEXCOORD1; // change this to distance to light and pass from vert to frag
};

// -------------------------------------
fragmentInput vert(vertexInput i){

    fragmentInput o;
    
    o.pos = mul(UNITY_MATRIX_MVP, i.vertex);
    o.posWorld = mul(_Object2World, i.vertex);
    
    o.uv = float2(i.uv);
    o.color = i.color;
    
    return o;
}

// -------------------------------------
float4 frag(fragmentInput i) : COLOR {
                    
    // get value from normal map and sub 0.5 and mul by 2 to change RGB range 0..1 to normal range -1..1
    float3 normalDirection = (tex2D(_NormalTex, i.uv).xyz - 0.5f) * 2.0f;
    
    // mul by world to object matrix, which handles rotation, etc
    normalDirection = float3(mul(float4(normalDirection, 0.5f), _World2Object));
    
    // negate Z so that lighting works as expected (sprites further away from the camera than a light are lit, etc.)
    normalDirection.z *= -1;
    
    // normalize direction
    normalDirection = normalize(normalDirection); 
               
    // dist to point light
    float3 vertexToLightSource = float3(_WorldSpaceLightPos0) - i.posWorld;
    float3 distance = length(vertexToLightSource);    

    // calc attenuation
    float attenuation = 1.0 / distance; 
    float3 lightDirection = normalize(vertexToLightSource);

    // calc diffuse lighting
    float normalDotLight = dot(normalDirection, lightDirection);
    float diffuseLevel = attenuation * max(0.0, normalDotLight);
    
    // calc specular ligthing
    float specularLevel = 0.0;
    // make sure the light is on the proper side
    if (normalDotLight > 0.0){
    
        // since orthographic
        float3 viewDirection = float3(0.0, 0.0, -1.0);
        specularLevel = attenuation * pow(max(0.0, dot(reflect(-lightDirection, normalDirection), viewDirection)), _Shininess);
    }

    // calc color components
    float4 diffuseColor = tex2D(_MainTex, i.uv);
    float3 diffuseReflection = float3(diffuseColor) * diffuseLevel * i.color * float3(_LightColor0);
    float3 specularReflection = float3(_SpecularColor) * specularLevel * i.color * float3(_LightColor0);
    
    // use the alpha from diffuse. mul by diffuseColor.a to resolve issues with transparency on overlapping sprites in same FRenderLayer
    return diffuseColor.a * float4(diffuseReflection + specularReflection, diffuseColor.a);
}    

ENDCG        
        } // end Pass
// -------------------------------------
// -------------------------------------
 
   } // end SubShader
   
   // fallback shader - comment out during dev
   // Fallback "Diffuse"
}

Normal Mapped Lighting Shader Class:

In order to use the Normal Mapped Lighting shader above, need to add a Normal Mapped Lighting shader class with a base class of FShader (note the Futile 0.91.0 shader interface is different). This class allows the shader to be added to a FSprite, and sets the initial parameters which are utilized as the uniform inputs in the shader.

using UnityEngine;

// ------------------------------------------------------------------------
// Supports normal mapped lighting
// ------------------------------------------------------------------------
public class SRLightingShader : FShader {

	private string _normalTexture;
	private float _shininess;
	private Color _diffuseColor;
	private Color _specularColor;
	
	// ------------------------------------------------------------------------
	// normalTexture = full path/name to normal map for corresponding main texture for this mat: e.g. Images/tiles_n
	// ------------------------------------------------------------------------
	public SRLightingShader(string normalTexture, float shininess, Color diffuseColor, Color specularColor) : 
                           base("SRLighting", Shader.Find("Futile/SRLighting")){

                // assign parms
		_normalTexture = normalTexture;
		_shininess = shininess;
		_diffuseColor = diffuseColor;
		_specularColor = specularColor;

                // ensure Apply gets called
		needsApply = true;
	}

	// ------------------------------------------------------------------------
        // applies these parameters to the material for the shader
	// ------------------------------------------------------------------------
	override public void Apply(Material mat){

		// load normal texture for this shader
		Texture2D normalTex = Resources.Load(_normalTexture) as Texture2D;		
		mat.SetTexture("_NormalTex", normalTex);
		mat.SetFloat("_Shininess", _shininess);
		mat.SetColor("_Color", _diffuseColor); // diffuse
		mat.SetColor("_SpecColor", _specularColor);
	}
}

Adding Normal Mapped Lighting Shader to a FSprite:

To keep the FRenderLayer batching sane, define your lighting shader before creating the FSprites, and then set each FSprite.shader to the previously created lighting shader so each sprite will continue to be properly batched.

// SRLightingShader(string normalTexture, float shininess, Color diffuseColor, Color specularColor)
SRLightingShader lightingShader = new SRLightingShader(ROCKS_NORMAL, 2.5f, Color.white, Color.white);

The Normal Mapped Lighting shader defined above may now be added to an FSprite:

// sprite uses the SRLightingShader for normal mapped lighting
FSprite rockSprite = new FSprite(ROCKS_SPRITE); 
// all sprites in same atlas must use same shader instance to properly batch
rockSprite.shader = lightingShader; 
Futile.stage.AddChild(rockSprite);

Here are the shader settings as seen in the inspector for the FRenderLayer:

Shader - Settings

Adding a Light GameObject:

Next, at least one point Light GameObject needs to be added to the scene. The light must be a point light and set to Render Mode = Important. The Z depth of the light needs to be negative so it is facing the scene and the depth of the light will control the brightness of the spot.

// add light gameobject
lightGameObject = new GameObject("Light");
lightGameObject.transform.localPosition = new Vector3(0, 0, lightDepth);

// add lightsource to it and configure
lightSource = lightGameObject.AddComponent<Light>();
lightSource.color = Color.white;
lightSource.intensity = 8;
lightSource.range = 375;
lightSource.type = LightType.Point;
lightSource.renderMode = LightRenderMode.ForcePixel; // ForcePixel = Important

Here are the light settings as seen in the inspector for the Light Game Object:

Light - Settings

Note: each Light added to the scene increases the number of draw calls by 1.

Creating a Normal Map

Next you need a texture and a normal lighting map for that texture. Few options for creating normal maps of existing sprites include Sprite Illuminator and Sprite Lamp.

The texture and normal light map below were generated using Filter Forge:

Rocks Rocks - Normal Map

And here are the texture and normal light map settings in Unity. The important bit is that the Import Type for the normal light map is set to Lightmap.

Normalmap and Texture Settings

Here is an image from the Unity editor where you can see a single FRenderLayer is using an atlas for the texture and normalmap for the tiles in that layer. As long as the sprites are in the same texture/normalmap atlas, the batching should keep them in a single FRenderLayer just like the Futile.Basic shader.

Normalmap - Full Unity UI

Also note that if you have overlapping sprites and are seeing lighting artifacts from the base sprite being lit and then the overlapping sprite being lit again and appearing brighter, you will need to separate out the overlapping sprites to their own FRenderLayer.

In the case of Dr. Spacezoo, the ground has a trim layer that softens the transition between tiles. Without having the trim tile in it’s own FRenderLayer, the trim tile would be 2x the brightness since it would receive the ForwardAdd lighting results from the sprite under it and its sprite. By separating the trim into it’s own FRenderLayer (by placing Trim sprites in their own atlas), the ForwardAdd lighting pass is able to properly light the scene:

Update 2015-03-24: It’s not necessary to batch the overlapping sprites into separate FRenderLayers. Updated the fragment shader above to multiply the final fragment output by the diffuse alpha value, which resolves the issues with the transparent parts of overlapping sprites appearing brighter.

    // use the alpha from diffuse. mul by diffuseColor.a to resolve issues with transparency on overlapping sprites in same FRenderLayer
    return diffuseColor.a * float4(diffuseReflection + specularReflection, diffuseColor.a);

Normal Mapped Lighting - Trim layers

Normal Mapped Lighting Example Project:

A full sample project (tested on Unity Pro 4.6.2 and Futile 0.92.0 (unstable branch)) may be found on github: https://github.com/smashriot/SRNormalLighting

Normalmap Lighting Example

References and Further Reading:

Did a lot of reading and experimentation to get the normal mapped lighting shader working, and here is a list of references roughly in descending order of inspiration/informative.

http://www.alkemi-games.com/a-game-of-tricks/
http://indreams-studios.com/post/writing-a-spritelamp-shader-in-unity/
http://indreams-studios.com/SpriteLamp.shader
https://www.youtube.com/watch?v=bqKULvitmpU (P5 N*L)
https://www.youtube.com/watch?v=hDJQXzajiPg (P1)
http://docs.unity3d.com/Manual/SL-VertexFragmentShaderExamples.html
http://docs.unity3d.com/Manual/SL-BuiltinValues.html
http://docs.unity3d.com/Manual/SL-BuiltinIncludes.html
http://docs.unity3d.com/Manual/SL-PassTags.html
http://http.developer.nvidia.com/CgTutorial/cg_tutorial_chapter01.html
http://forum.unity3d.com/threads/68402-Making-a-2D-game-for-iPhone-iPad-and-need-better-performance
http://en.wikibooks.org/wiki/Cg_Programming/Unity/Shading_in_World_Space
http://www.verajankorva.com/cms/?p=203

In Closing:

Hope you found this article on implementing a Normal Mapped Lighting Shader in Futile/Unity interesting,

Jesse from Smash/Riot