Smash/Riot Logo

Smash/Riot

Focusing on Interactive Media and Unique Experiences

Normal Mapped Lighting for 2D Sprites in Futile for Unity

Use a lighting normal map and a custom shader to create normal mapped lighting for 2D Sprites

Jesse/SmashRiot

8-Minute Read

Normal Mapped Lighting for 2D Sprites in Futile for Unity Banner

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

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:

Unity 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:

Unity 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.

In Closing

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

Jesse from Smash/Riot

Recent Posts

About

Smash/Riot LLC focuses on interactive media, virtual experiences, games, visual effects, VR/AR, and networked experiences for modern platforms.